diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index fdfd8b9b4..a20112922 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -75,3 +75,51 @@ jobs: using PlantSimEngine DocMeta.setdocmeta!(PlantSimEngine, :DocTestSetup, :(using PlantSimEngine); recursive=true) doctest(PlantSimEngine) + graph-editor-e2e: + name: Graph editor frontend and E2E + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + actions: write + contents: read + steps: + - uses: actions/checkout@v6 + - uses: julia-actions/setup-julia@v3 + with: + version: "1" + - uses: julia-actions/cache@v3 + - name: Configure graph editor test environment + shell: julia --project=test --color=yes {0} + run: | + using Pkg + Pkg.develop(PackageSpec(path=pwd())) + Pkg.instantiate() + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + - name: Check and build frontend + working-directory: frontend + run: | + npm run typecheck + npm test + npm run build + - name: Install Chromium + working-directory: frontend + run: npx playwright install --with-deps chromium + - name: Run graph editor end-to-end tests + working-directory: frontend + run: npm run test:e2e + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: graph-editor-playwright-report + path: | + frontend/playwright-report + frontend/test-results + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index f81ea322b..e6b06a6cf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ docs/Manifest.toml test/Manifest.toml docs/build/ benchmark/Manifest.toml -frontend \ No newline at end of file +/frontend/node_modules/ +/frontend/.vite/ +/frontend/test-results/ +/frontend/playwright-report/ diff --git a/AGENTS.md b/AGENTS.md index 119f35fd3..ea84a2b2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,340 +1,221 @@ # PlantSimEngine Agent And Developer Guide -This file is the maintainer-facing summary of how PlantSimEngine works internally. -It is meant for humans and coding agents making changes to the package. - -PlantSimEngine is a Julia engine for composing process models on either: - -- a single shared status (`ModelMapping{SingleScale}` / legacy `ModelList`) -- a multiscale MTG scene (`GraphSimulation`) - -The package is built around four ideas: - -1. Models declare `inputs_`, `outputs_`, and optionally `dep`. -2. The engine compiles a dependency graph from those declarations. -3. Runtime state is reference-based (`Status`, `RefVector`), so coupling is often aliasing, not copying. -4. Multiscale and multirate configuration can change where an input comes from, how it is transported, and when it is sampled. - -## What The Package Supports - -- Single-scale process composition with automatic soft-dependency inference. -- Hard dependencies declared explicitly and called manually from model code. -- MTG-based multiscale simulations with cross-scale variable mappings. -- Cross-scale scalar sharing through shared `Ref`s. -- Cross-scale multi-node sharing through `RefVector`s. -- Cross-scale writes, where a variable computed at one scale is materialized as an input at another scale. -- Same-scale variable aliasing and renaming. -- Cycle breaking through `PreviousTimeStep`. -- Multi-rate execution through `ModelSpec`, `ClockSpec`, and temporal policies. -- Explicit or inferred `InputBindings` between producers and consumers. -- Meteo resampling/aggregation per model in multi-rate MTG runs. -- Output routing (`:canonical` vs `:stream_only`) and online output export (`OutputRequest`). -- Parallel single-scale execution when model traits allow it. - -## Core Runtime Objects - -### Processes and models - -- All models subtype `AbstractModel`. -- `@process` creates an abstract process type such as `AbstractGrowthModel`. -- Process identity comes from the abstract process type, not the concrete model name. -- The model execution contract is: +PlantSimEngine composes process models over a unified composite-model/object registry. +The repository contains one scenario compiler and runtime: the composite-model/object +API. + +## Core Model Contract + +- Every model subtypes `AbstractModel`. +- `@process` defines the abstract process type. +- `process(model)` identifies the process. +- `inputs_(model)` and `outputs_(model)` declare status variables. +- `meteo_inputs_(model)` and `meteo_outputs_(model)` declare environment + variables. +- `dep(model)` optionally returns model-author defaults using `Input(...)` and + `Call(...)`. +- Kernels implement: ```julia PlantSimEngine.run!(model, models, status, meteo, constants, extra) ``` -- `inputs_(model)` and `outputs_(model)` are the authoritative declarations. -- `variables(model)` is `merge(inputs_(model), outputs_(model))`. -- Do not rely on a variable being both an input and an output under the same name: `merge` means the later declaration wins. - -### Status - -- `Status` is a wrapper around a `NamedTuple` of `Ref`s. -- Reading a field dereferences it. Writing a field mutates the underlying `Ref`. -- This aliasing behavior is intentional and is the basis of most coupling. -- In single-scale runs, vector-valued user inputs are flattened to one timestep value and updated per timestep with `set_variables_at_timestep!`. - -### RefVector - -- `RefVector` is an `AbstractVector` of `Base.RefValue`s. -- It is used when one model input must see a vector of references coming from many statuses. -- Reading a `RefVector` dereferences each underlying status cell. -- Writing into a `RefVector` mutates the source statuses. -- `RefVector` order follows MTG traversal order during initialization, not a semantic plant order. - -### Mapping wrappers - -- `MultiScaleModel` wraps one model plus a multiscale mapping declaration. -- `ModelSpec` wraps one model plus scenario-level runtime configuration: - `multiscale`, `timestep`, `input_bindings`, `meteo_bindings`, `meteo_window`, `output_routing`, and `scope`. -- `ModelMapping` is the normalized mapping container used by current entry points. -- Legacy `ModelList` still exists, but it is compatibility plumbing and should not be treated as the main abstraction for new work. - -### Simulation wrappers +`models` is a process-keyed bundle compiled from `Calls(...)`. `extra` is a +`RunContext` and provides `run_call!`, `call_targets`, and lifecycle access. -- `DependencyGraph` holds root dependency nodes plus unresolved dependencies. -- `GraphSimulation` holds the MTG, statuses, status templates, reverse mappings, dependency graph, models, model specs, outputs, and temporal state. +## CompositeModel Structure -## Dependency Graph Under The Hood +- `CompositeModel` owns a `ObjectRegistry`, model applications, instances, and an + environment. +- `Object` is one runtime entity with stable `ObjectId`, labels, parent, + geometry, and `Status`. +- Plant architecture is not prescribed. Users choose scales and topology. +- `CompositeModelTemplate` and `ObjectInstance` reuse the same model definitions across + several plants or objects. +- `Override` replaces one application model for selected objects without + splitting the logical application. +- `objects_from_mtg` and `CompositeModel(mtg; ...)` adapt MTG topology into the same + registry. -### Hard dependencies +## Model Applications -- Hard dependencies are declared with `dep(::ModelType)`. -- A hard dependency means: "this model directly calls another model from inside its own `run!` implementation." -- Hard dependencies are represented by `HardDependencyNode`. -- They are executed manually by the parent model. The runtime does not automatically recurse into hard dependencies. -- Hard dependencies can be same-scale or explicitly multiscale. +Use one configuration grammar: -Important nuance: - -- A hard dependency does not become an independent soft-dependency node under the parent. -- But it still matters for graph construction, because the graph compiler aggregates the root model's hard-dependency subtree when computing that root's effective inputs and outputs. -- In multiscale graph building, if another model depends on a process that exists only as a nested hard dependency, the code resolves that dependency back to the master soft node that owns that hard subtree. - -So "hard dependencies do not directly participate in the soft graph" is true for execution structure, but false if interpreted as "their IO is irrelevant to graph compilation." - -### Soft dependencies - -- Soft dependencies are inferred by matching model inputs against outputs. -- Matching is name-based after variable flattening, not based on a richer semantic contract. -- Same-scale soft dependencies are built after hard-dependency trees are known. -- A process cannot also list one of its hard dependencies as a soft dependency. -- `PreviousTimeStep` variables are removed from current-step soft dependency inference. -- Soft dependencies are represented by `SoftDependencyNode`. -- A soft node may have multiple parents. -- A node is considered runnable once all of its parent nodes have already run for the current traversal. -- If no producer output matches an input, no soft edge is added. Soft-edge construction does not itself fail on missing producers. - -### Single-scale graph build - -Single-scale graph construction is: - -1. Build `HardDependencyNode`s for each declared process. -2. Attach explicit hard-dependency children under their parents. -3. Traverse each hard-dependency root and collect its effective inputs and outputs. -4. Build one `SoftDependencyNode` per hard-dependency root. -5. Infer parent and child links by matching inputs to outputs. - -### Multiscale graph build - -Multiscale graph construction is more involved: - -1. Normalize the user mapping into `ModelMapping`. -2. Build per-scale hard-dependency graphs. -3. Resolve multiscale hard dependencies declared across scales. -4. Compute per-scale effective inputs and outputs for each hard-dependency root. -5. Build one `SoftDependencyNode` per root process per scale. -6. Compile mapped variables and reverse mappings. -7. Infer same-scale soft dependencies. -8. Infer cross-scale soft dependencies from mapped variables and reverse mappings. -9. If a dependency points to a nested hard dependency, redirect it to the owning soft node. -10. Check the final graph for cycles. - -### Cycle handling - -- The graph is expected to be acyclic. -- The official way to break a same-step cycle is `PreviousTimeStep`. -- `PreviousTimeStep` breaks cycles by suppressing current-step edge creation, not by adding special scheduler logic. -- In multiscale runs, cycle detection happens after the cross-scale graph is assembled. -- Single-scale `dep(...)` relies mostly on builder-time guards. Multiscale `dep(mapping)` also runs an explicit global cycle check on the final soft graph. - -## Multiscale Mapping Model +```julia +ModelSpec(model; name=:application) |> +AppliesTo(selector) |> +Inputs(...) |> +Calls(...) |> +TimeStep(Dates.Hour(1)) |> +Environment(...) +``` -### Mapping modes +- `AppliesTo` selects where the model runs. +- `Inputs` declares value dependencies. +- `Calls` declares manually executable hard dependencies. +- `Updates(:x; after=:producer)` orders intentional duplicate writers. +- `OutputRouting(; x=:stream_only)` excludes an output from canonical + ownership while retaining its stream. -PlantSimEngine distinguishes three mapping modes: +## Selectors -- `SingleNodeMapping(scale)`: one scalar value is read from one source scale. -- `MultiNodeMapping(scales)`: one input reads a vector of values from many source nodes. -- `SelfNodeMapping()`: a source scale must expose a scalar reference to itself so other scales can share it. +Multiplicity: -The runtime carrier is `MappedVar`, which stores: +- `One(...)` +- `OptionalOne(...)` +- `Many(...)` -- the mapping mode -- the local variable name -- the source variable name -- the resolved default value +Scope and topology: -### Supported mapping forms +- `SceneScope()` +- `Self()`: the current object +- `Subtree()`: the current object and its descendants +- `SelfPlant()`: the current plant instance/root +- `Ancestor(...)` +- `Scope(name)` +- `Kind(...)`, `Species(...)`, `Scale(...)`, `Relation(...)` -These are the important user-level forms and what they become internally: +`Self()` never means the model, species, or plant unless the current object is +itself that plant. -| User form | Meaning | Runtime shape | -| --- | --- | --- | -| `:x => :Plant` | scalar read from one `:Plant` node | shared `Ref` | -| `:x => (:Plant => :y)` | scalar read with renaming | shared `Ref` | -| `:x => [:Leaf]` | vector read from all `:Leaf` nodes | `RefVector` | -| `:x => [:Leaf, :Internode]` | vector read from several scales | `RefVector` | -| `:x => [:Leaf => :a, :Internode => :b]` | vector read with per-scale renaming | `RefVector` | -| `PreviousTimeStep(:x) => ...` | lagged mapping, excluded from same-step dependency build | lagged input | -| `PreviousTimeStep(:x)` | pure cycle-breaking marker | local/default value | -| `:x => (Symbol(\"\") => :y)` | same-scale rename | `RefVariable` alias | +## Value Coupling -### Mapping compilation pipeline +The compiler resolves `Inputs(...)` to reference carriers: -`mapped_variables(...)` does not just mirror user syntax. It compiles it. +- one source uses a shared `Ref`; +- many homogeneous sources use `RefVector`; +- heterogeneous sources use `ObjectRefVector`; +- temporal policies read typed output streams. -The main passes are: +Use `input_carrier`, `input_value`, `explain_bindings`, and +`has_reference_carrier` instead of inspecting internal fields. -1. Start from effective per-scale inputs and outputs collected from hard-dependency roots. -2. Add variables that are outputs of one scale but must appear as inputs at another scale. -3. Convert scalar cross-scale reads into self-mapped outputs on the source scale so one shared `Ref` exists. -4. Resolve default values recursively back to the ultimate producer. -5. Convert mapping descriptors into runtime carriers: - - scalar mappings become shared `Ref`s - - multi-node mappings become empty `RefVector`s - - same-scale renames become `RefVariable` +Same-object input/output matches are inferred when unique. Cross-object +coupling should be explicit with `Inputs(...)`. -### Reverse mapping and status wiring +## Hard Calls -- Reverse mapping is computed before the reference conversion pass. -- Reverse mapping answers: "when a source node is initialized, which target scale/vector inputs should receive a reference to this source variable?" -- Reverse mapping excludes scalar `SingleNodeMapping` edges when `all=false`, because scalar sharing is already handled by shared `Ref`s. +Hard dependencies are parent-controlled: -During `init_node_status!`: +1. Declare them with model-level `Call(...)` or scenario-level `Calls(...)`. +2. Execute all resolved targets with `run_call!(extra, name)`. It always + returns a vector-like `CallTargets` collection. +3. For selective or iterative execution, inspect `call_targets(extra, name)` + and execute individual `CallTarget`s with `run_call!`. -1. A copy of the scale template is made. -2. `:node => Ref(node)` is injected. -3. Remaining uninitialized variables may be filled from MTG attributes. -4. The template becomes a `Status`. -5. The status is pushed into `statuses[scale]`. -6. If this node feeds any downstream `RefVector`, its `Ref`s are pushed into those target vectors. -7. The status is stored on the MTG node under `:plantsimengine_status`. +`run_call!` defaults to `publish=false`, which is appropriate for iterative +trial states. Publish only the accepted state with `publish=true`. -### Copies vs references +Applications used exclusively as call targets are not run by the root +scheduler and do not receive inferred soft bindings. -- MTG attribute initialization copies plain values into the status. -- If the MTG attribute itself is already a `Ref`, that `Ref` is preserved. -- The runtime cannot create a live reference directly into a dict-backed MTG attribute. -- Cross-scale sharing is reference-based once the status exists. +## Time -## Multi-Rate Runtime - -Multi-rate behavior is layered on top of the multiscale MTG runtime. +- `TimeStep(Dates.Period)` configures application cadence. +- `timespec(model)` provides a model default. +- `timestep_hint(model)` validates compatibility when cadence comes from the + environment base step. +- `HoldLast`, `Interpolate`, `Integrate`, and `Aggregate` configure temporal + input policies. +- `PreviousTimeStep(:x)` breaks same-step cycles. -### Timing and policies +Dates periods are converted using meteorology `duration`. Model code should not +know its scenario timestep unless the scientific model explicitly requires it. -- `timespec(model)` defines the model's default clock. The default is `ClockSpec(1.0, 0.0)`. -- `ModelSpec.timestep` can override runtime clock selection. -- `output_policy(model)` declares per-output temporal policy defaults. - -Supported schedule policies are: - -- `HoldLast()`: use the latest available producer value. -- `Interpolate()`: interpolate or hold/extrapolate producer streams. -- `Integrate()`: reduce values over the consumer window, default reducer is `SumReducer()`. -- `Aggregate()`: reduce values over the consumer window, default reducer is `MeanReducer()`. +## Environment -### ModelSpec configuration surface +- `Environment(...)` configures provider selection and source remapping. +- Global meteorology and spatial backends use the same model-facing contract. +- Spatial object-to-cell bindings are compiled and cached. +- `move_object!`, `update_geometry!`, or + `mark_environment_binding_dirty!` invalidate affected bindings. +- `scatter_environment_outputs!` writes model-produced microclimate variables + back to mutable backends. -`ModelSpec` is the configuration point for scenario-specific runtime behavior. +## Lifecycle -It can define: +- `add_organ!` is the high-level operation for MTG-backed growth. It creates + the node, reuses the model's MTG status policy, applies initial values, + attaches the status, and registers the object. +- `register_object!`, `remove_object!`, and `reparent_object!` mutate topology. +- Use `register_object!` directly only when the caller already owns a fully + initialized `Object`. +- Structural changes refresh application targets, value carriers, call + targets, writer checks, and schedules between timesteps. +- Geometry changes refresh only affected environment bindings when possible. +- Removed objects keep their historical output samples. -- `multiscale`: mapping declaration -- `timestep`: runtime clock -- `input_bindings`: explicit producer selection for consumer inputs -- `meteo_bindings`: per-model weather aggregation -- `meteo_window`: weather window selection strategy -- `output_routing`: `:canonical` or `:stream_only` -- `scope`: `:global`, `:self`, `:plant`, `:scene`, `ScopeId`, or callable +## Outputs -### Input binding inference +- `run!(model; outputs=:none)` starts a fresh timeline and returns + `Simulation`; use `outputs=:all` or output requests to retain streams. +- `continue!(simulation)` and `step!(simulation)` advance the same timeline. +- `outputs(sim)` exposes retained typed streams. +- `OutputRequest` selects retained/resampled outputs. +- `collect_outputs(sim)` materializes output rows. +- `explain_output_retention(sim)` reports why each stream is retained. -- If explicit `InputBindings` are absent, the package tries to infer bindings from the dependency graph and mapping. -- Unique same-scale producers win first. -- Unique cross-scale producers are accepted when unambiguous. -- Existing multiscale mapping hints can disambiguate some cross-scale cases. -- Ambiguity is an error and must be resolved explicitly. - -### Runtime sequence in multi-rate MTG mode - -For each dependency node and each status at that node's scale: - -1. Decide whether the model should run at the current time according to its clock. -2. Resolve consumer inputs from temporal state with explicit or inferred bindings. -3. Sample or aggregate meteo for the model. -4. Call the model's `run!`. -5. Publish outputs back into temporal caches and streams. -6. Materialize any requested online exports. - -Important consequences: - -- In non-multirate MTG runs, cross-scale coupling is mostly direct aliasing through shared refs. -- In multirate MTG runs, temporal state can overwrite consumer inputs just before execution. -- Multi-rate MTG runs are currently forced to sequential execution. - -## Configurations Developers Must Keep In Mind - -A variable seen by a model may be in any of these supported configurations: +Streams are keyed by application, object, and variable so repeated processes do +not overwrite each other. -- Plain local status value initialized by the user. -- Plain local status value initialized from MTG node attributes. -- Output computed locally at the same scale. -- Same-scale alias of another local variable through `RefVariable`. -- Scalar value mapped from another scale through a shared `Ref`. -- Vector of references mapped from one or many other scales through `RefVector`. -- Output computed at one scale and written into another scale, which means it is injected as an input on the receiving scale during mapping compilation. -- Value marked as `PreviousTimeStep`, which removes it from same-step dependency inference. -- Input resolved from a hard dependency that is called manually inside another model. -- Input resolved from temporal streams instead of directly from the current status value. -- Input bound explicitly with `InputBindings`. -- Input bound implicitly by inference from producers and mappings. -- Input sampled with `HoldLast`, `Interpolate`, `Integrate`, or `Aggregate`. -- Output published canonically into status state. -- Output published as `:stream_only`, meaning it participates in temporal streams but not canonical output ownership. -- Value partitioned by scope (`:global`, `:self`, `:plant`, `:scene`, or custom scope function). +## Performance Rules -When changing dependency, mapping, or runtime code, assume all of these modes can exist in the same simulation. - -## Execution Semantics And Important Caveats - -- Soft-dependency order controls model order. MTG topology does not define execution order within a scale. -- Within one scale, execution order follows the order of `statuses[scale]`, which comes from MTG traversal at initialization time. -- `SingleNodeMapping` assumes the source node is unique at runtime. The mapping layer does not enforce uniqueness. -- `RefVector` ordering is traversal order, not a guaranteed biological ordering. -- Hard dependencies are manual calls. If model code stops calling them, the declared hard dependency no longer executes. -- Hard dependencies still influence graph compilation through their effective inputs and outputs. -- Multiscale redirection from nested hard dependencies back to the owning soft node is implemented with upward walking through parent links and a defensive depth guard. Treat that path as fragile. -- MTG topology changes after `init_statuses` leave `statuses`, node attributes, and populated `RefVector`s stale. Reinitialize after topology changes. -- Same-scale renaming does not create a graph-wide shared ref. It creates a per-status alias. -- `parent_vars` is dependency metadata, not a full provenance graph, and in multiscale builds it can be overwritten when a node has both same-scale and cross-scale parents. -- Duplicate canonical publishers for one `(scale, variable)` are invalid in multi-rate mode unless non-canonical producers are marked `:stream_only`. -- User `extra` arguments are not allowed in MTG runs because `GraphSimulation` already occupies that slot. -- String scale names still work in many places but are deprecated. Prefer `Symbol` scales. -- `ModelList` is deprecated as the primary API. Prefer `ModelMapping`. -- `run_node_multiscale!` currently uses `node.simulation_id[1]` as the visitation guard. Treat that code carefully if you touch traversal semantics. -- Some variable collection helpers use set-like flattening, so collection order is not always stable. Do not attach semantics to incidental variable ordering. +- Keep model parameters and status values generic; do not force `Float64`. +- Preserve concrete model, status, carrier, and stream types. +- Do not copy values when a reference carrier is sufficient. +- Keep dynamic dispatch at compiled batch boundaries, not per object. +- Preserve cached model bundles and homogeneous execution batches. +- Test allocations for hot loops that run over many organs. ## High-Signal Files -- `src/PlantSimEngine.jl`: module layout and exports. -- `src/Abstract_model_structs.jl`: `AbstractModel` and `process`. -- `src/processes/process_generation.jl`: `@process`. -- `src/processes/models_inputs_outputs.jl`: model declarations and runtime traits. -- `src/variables_wrappers.jl`: `UninitializedVar`, `PreviousTimeStep`, `RefVariable`. -- `src/component_models/Status.jl`: reference-based status container. -- `src/component_models/RefVector.jl`: vector of references. -- `src/dependencies/*`: hard and soft dependency graph construction and traversal. -- `src/mtg/MultiScaleModel.jl`: mapping syntax normalization. -- `src/mtg/ModelSpec.jl`: runtime configuration wrapper. -- `src/mtg/mapping/*`: mapping compilation, reverse mapping, initialization helpers. -- `src/mtg/initialisation.jl`: status creation and MTG wiring. -- `src/mtg/GraphSimulation.jl`: simulation wrapper. -- `src/time/multirate.jl`: clocks, policies, temporal storage types. -- `src/time/runtime/*`: input resolution, scopes, publishers, meteo sampling, output export. -- `src/run.jl`: single-scale and multiscale execution. - -## Practical Rule For Future Changes - -If you change dependency, mapping, or runtime behavior, re-check all of these questions: - -1. Does it still work for both single-scale and MTG runs? -2. Does it preserve aliasing semantics for `Status` and `RefVector`? -3. Does it preserve the distinction between hard dependencies and soft dependencies? -4. Does it still handle scalar mappings, vector mappings, same-scale aliasing, and cross-scale writes? -5. Does it still behave correctly with `PreviousTimeStep`? -6. Does it still work when input bindings are inferred instead of explicit? -7. Does it still work in multi-rate mode with temporal policies and scoped streams? -8. Does it remain correct if the producer is nested under a hard dependency? +- `src/composite_model_api.jl`: dependency-ordered include boundary for the sole + CompositeModel/Object compiler and runtime. +- `src/composite_model/registry_topology.jl`: objects, registry, templates, + instances, overrides, topology, and lifecycle ownership. +- `src/composite_model/selectors.jl`: selector normalization and resolution. +- `src/composite_model/compilation.jl`: applications, carriers, calls, writer + validation, schedules, and structured compilation explanations. +- `src/composite_model/environment_bindings.jl`: global/spatial environment + bindings and invalidation. +- `src/composite_model/runtime_outputs.jl`: execution, temporal streams, hard-call + publication, retention, and output collection. +- `src/composite_model/scenario_dsl.jl`: small scenario construction helpers. +- `src/ModelSpec.jl`: model application configuration. +- `src/component_models/Status.jl`: reference-based status. +- `src/component_models/RefVector.jl`: homogeneous reference vectors. +- `src/time/multirate.jl`: clocks and temporal policies. +- `src/time/runtime/clocks.jl`: Dates-based timing. +- `src/time/runtime/meteo_sampling.jl`: weather sampling. +- `src/time/runtime/environment_backends.jl`: environment backend contract. +- `test/test-unified-model-object-api.jl`: broad integration coverage. +- `test/test-model-*.jl`: focused CompositeModel/Object behavioral contracts. + +## Change Checklist + +When changing compilation or runtime behavior, verify: + +1. one object and many objects; +2. same-object and cross-object inputs; +3. `One`, `OptionalOne`, and `Many`; +4. hard calls and iterative publication; +5. duplicate writers and `Updates`; +6. multirate policies and `PreviousTimeStep`; +7. global and spatial environments; +8. object creation, removal, reparenting, and movement; +9. templates, instances, and overrides; +10. generic numeric types and allocation-sensitive execution. + +## API evolution policy + +This project is in active development and has no stable public API yet. + +When implementing API changes: + +- Do not preserve backward compatibility unless explicitly requested. +- Do not add deprecated aliases, compatibility wrappers, fallback methods, old keyword support, migration layers, or dual APIs. +- Prefer a clean breaking change over supporting both old and new APIs. +- Update all internal call sites, tests, and documentation to the new API. +- Remove obsolete code instead of keeping it. +- If existing tests fail because they expect the old API, update the tests to match the new API. +- Before adding compatibility code, stop and ask for confirmation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac4e3652..cce9b351a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## v0.15.0 + +### Breaking changes + +- Hard dependencies now execute through + `run_call!(context::RunContext, name::Symbol; ...)`, which executes every + selector-resolved target and always returns a vector-like `CallTargets` + collection. +- The singular hard-call accessor and string-name hard-call methods were + removed. Use `only(call_targets(context, :name))` for fine-grained access to + a `One` dependency. +- Direct recursive dependency execution is no longer supported. Model kernels + must run inside a compiled `CompositeModel` and receive a `RunContext`. + +### Added + +- `CallTargets`, a cached `AbstractVector` view over compiled hard-call targets + that is allocation-free to retrieve. +- `run_call!(context, name)` for the common execute-all operation while + retaining `run_call!(target::CallTarget)` for selective, per-target, and + iterative control. + ## v0.14.0 Changes in this section are based on the git history since [`v0.13.2`](https://github.com/VirtualPlantLab/PlantSimEngine.jl/releases/tag/v0.13.2), corresponding to the GitHub compare view for [`v0.14.0`](https://github.com/VirtualPlantLab/PlantSimEngine.jl/compare/v0.13.2...v0.14.0). @@ -21,7 +43,7 @@ and substantially expands the documentation. The main user-facing breaking change in this release is the move toward `Symbol`-based scale names in mappings and multi-scale configuration. Code that still uses string scales such as `"Leaf"` or `"Plant"` should be updated to use -symbols such as `:Leaf` and `:Plant`, especially in `ModelMapping(...)`, +symbols such as `:Leaf` and `:Plant`, especially in `PlantSimEngine.ModelMapping(...)`, `MultiScaleModel(...)`, and explicit multi-rate bindings. `ModelList` is also on the deprecation path in favor of `ModelMapping`, so this release is a good time to migrate mapping code to the newer API. @@ -77,12 +99,12 @@ to migrate mapping code to the newer API. ### Deprecated -- `run!(::ModelList, ...)` is deprecated. Use `run!(ModelMapping(...), ...)` +- `run!(::ModelList, ...)` is deprecated. Use `run!(PlantSimEngine.ModelMapping(...), ...)` instead. - `run!` with collections of `ModelList` is deprecated. Use collections of `ModelMapping` instead. - `run!(mtg, mapping::AbstractDict, ...)` is deprecated. Construct a - `ModelMapping(...)` first, or call `run!(mtg, ModelMapping(mapping), ...)`. + `PlantSimEngine.ModelMapping(...)` first, or call `run!(mtg, PlantSimEngine.ModelMapping(mapping), ...)`. - String scale names are deprecated in multi-scale mapping APIs. Use `Symbol` scales such as `:Leaf` instead of `"Leaf"`. - `ModelList` remains available for now but is being phased out in favor of @@ -93,7 +115,7 @@ to migrate mapping code to the newer API. #### 1. Replace ad hoc mappings with `ModelMapping` If you previously used `ModelList(...)` directly for single-scale runs, or a -plain `Dict` for MTG runs, migrate to `ModelMapping(...)`. +plain `Dict` for MTG runs, migrate to `PlantSimEngine.ModelMapping(...)`. Before: @@ -113,13 +135,13 @@ mapping = Dict( After: ```julia -leaf = ModelMapping( +leaf = PlantSimEngine.ModelMapping( process1 = Process1Model(), process2 = Process2Model(), status = (x = 1.0,), ) -mapping = ModelMapping( +mapping = PlantSimEngine.ModelMapping( :Leaf => (ToyAssimModel(),), :Plant => (ToyGrowthModel(),), ) @@ -133,7 +155,7 @@ When a model should run at a cadence different from the meteo, wrap it in Typical pattern: ```julia -mapping = ModelMapping( +mapping = PlantSimEngine.ModelMapping( :Leaf => ( ModelSpec(HourlyLeafModel()) |> TimeStepModel(1.0), ), @@ -180,7 +202,7 @@ If your mappings still use string scales, migrate them to symbols. Before: ```julia -mapping = ModelMapping( +mapping = PlantSimEngine.ModelMapping( "Leaf" => (ToyAssimModel(),), ) @@ -191,7 +213,7 @@ MultiScaleModel([:A => "Leaf"]) After: ```julia -mapping = ModelMapping( +mapping = PlantSimEngine.ModelMapping( :Leaf => (ToyAssimModel(),), ) diff --git a/Project.toml b/Project.toml index 7db8b1067..ecd7143d6 100644 --- a/Project.toml +++ b/Project.toml @@ -1,34 +1,37 @@ name = "PlantSimEngine" uuid = "9a576370-710b-4269-adf9-4f603a9c6423" -version = "0.14.0" +version = "0.15.0" authors = ["Rémi Vezy and contributors"] [deps] -AbstractTrees = "1520ce14-60c1-5f80-bbc7-55ef81b5835c" CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" -DataAPI = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" -FLoops = "cc61a311-1640-44b5-9fba-1b764f453329" +InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" MultiScaleTreeGraph = "dd4a991b-8a45-4075-bede-262ee62d5583" PlantMeteo = "4630fe09-e0fb-4da5-a846-781cb73437b6" -SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" Term = "22787eb5-b846-44ae-b979-8e399b8463ab" +[weakdeps] +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" + +[extensions] +PlantSimEngineGraphEditorExt = "HTTP" + [compat] -AbstractTrees = "0.4" CSV = "0.10" -DataAPI = "1.15" DataFrames = "1" Dates = "1.10" -FLoops = "0.2" +InteractiveUtils = "1.10" +JSON = "1.6.1" +HTTP = "1" Markdown = "1.10" MultiScaleTreeGraph = "0.15.1" PlantMeteo = "0.8.2" -SHA = "0.7.0" Statistics = "1.10" Tables = "1" Term = "2" diff --git a/README.md b/README.md index 39ae36e10..530b77bbf 100644 --- a/README.md +++ b/README.md @@ -9,369 +9,219 @@ [![DOI](https://zenodo.org/badge/571659510.svg)](https://zenodo.org/badge/latestdoi/571659510) [![JOSS](https://joss.theoj.org/papers/137e3e6c2ddc349bec39e06bb04e4e09/status.svg)](https://joss.theoj.org/papers/137e3e6c2ddc349bec39e06bb04e4e09) -- [PlantSimEngine](#plantsimengine) - - [Overview](#overview) - - [Unique Features](#unique-features) - - [Automatic Model Coupling](#automatic-model-coupling) - - [Flexibility with Precision Control](#flexibility-with-precision-control) - - [Multi-rate Execution](#multi-rate-execution) - - [Batteries included](#batteries-included) - - [Ask Questions](#ask-questions) - - [Installation](#installation) - - [Example usage](#example-usage) - - [Simple example](#simple-example) - - [Model coupling](#model-coupling) - - [Multiscale modelling](#multiscale-modelling) - - [Multi-rate modelling](#multi-rate-modelling) - - [Projects that use PlantSimEngine](#projects-that-use-plantsimengine) - - [Performance](#performance) - - [Make it yours](#make-it-yours) +PlantSimEngine is a Julia framework for composing soil-plant-atmosphere +simulations from reusable process models. -## Overview +A modeler writes generic kernels with: -`PlantSimEngine` is a comprehensive framework for building models of the soil-plant-atmosphere continuum. It includes everything you need to **prototype, evaluate, test, and deploy** plant/crop models at any scale, with a strong emphasis on performance and efficiency, so you can focus on building and refining your models. +- `inputs_` +- `outputs_` +- optional `dep`, `timespec`, `output_policy`, `meteo_inputs_`, and + `meteo_outputs_` traits +- `run!(model, models, status, meteo, constants, extra)` -**Why choose PlantSimEngine?** +A simulation author assembles those kernels on objects in a model with: -- **Simplicity**: Write less code, focus on your model's logic, and let the framework handle the rest. -- **Modularity**: Each model component can be developed, tested, and improved independently. Assemble complex simulations by reusing pre-built, high-quality modules. -- **Standardisation**: Clear, enforceable guidelines ensure that all models adhere to best practices. This built-in consistency means that once you implement a model, it works seamlessly with others in the ecosystem. -- **Optimised Performance**: Don't re-invent the wheel. Delegating low-level tasks to PlantSimEngine guarantees that your model will benefit from every improvement in the framework. Enjoy faster prototyping, robust simulations, and efficient execution using Julia's high-performance capabilities. - -## Unique Features - -### Automatic Model Coupling - -**Seamless Integration:** PlantSimEngine leverages Julia's multiple-dispatch capabilities to automatically compute the dependency graph between models. This allows researchers to effortlessly couple models without writing complex connection code or manually managing dependencies. - -**Intuitive Multi-Scale Support:** The framework naturally handles models operating at different scales—from organelle to ecosystem—connecting them with minimal effort and maintaining consistency across scales. - -### Flexibility with Precision Control - -**Effortless Model Switching:** Researchers can switch between different component models using a simple syntax without rewriting the underlying model code. This enables rapid comparison between different hypotheses and model versions, accelerating the scientific discovery process. - -### Multi-rate Execution - -**Mix model cadences in one simulation:** PlantSimEngine can run models at different timesteps within the same MTG simulation. This makes it possible to combine, for example, hourly leaf processes with daily plant balances and weekly reporting models without writing custom scheduling glue. - -**Explicit bindings between rates:** `TimeStepModel`, `InputBindings`, `MeteoBindings`, `ScopeModel`, and `OutputRequest` let you declare how model inputs, meteorology, and exported outputs should behave when rates differ. - -## Batteries included - -- **Automated Management**: Seamlessly handle inputs, outputs, time-steps, objects, and dependency resolution. -- **Iterative Development**: Fast and interactive prototyping of models with built-in constraints to avoid errors and sensible defaults to streamline the model writing process. -- **Control Your Degrees of Freedom**: Fix variables to constant values or force to observations, use simpler models for specific processes to reduce complexity. -- **Multi-Rate Scheduling**: Combine hourly, daily, and coarser models in the same simulation, with explicit policies for input aggregation and meteorological sampling. -- **High-Speed Computations**: Achieve impressive performance with benchmarks showing operations in the 100th of nanoseconds range for complex models (see this [benchmark script](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples/benchmark.jl)). -- **Parallelize and Distribute Computing**: Out-of-the-box support for sequential, multi-threaded, or distributed computations over objects, time-steps, and independent processes, thanks to [Floops.jl](https://juliafolds.github.io/FLoops.jl/stable/). -- **Scale Effortlessly**: Methods for computing over objects, time-steps, and [Multi-Scale Tree Graphs](https://github.com/VEZY/MultiScaleTreeGraph.jl). -- **Compose Freely**: Use any types as inputs, including [Unitful](https://github.com/PainterQubits/Unitful.jl) for unit propagation and [MonteCarloMeasurements.jl](https://github.com/baggepinnen/MonteCarloMeasurements.jl) for measurement error propagation. - -## Ask Questions +```julia +CompositeModel +Object +ModelSpec +AppliesTo +Inputs +Calls +Updates +TimeStep +Environment +``` -If you have any questions or feedback, [open an issue](https://github.com/VirtualPlantLab/PlantSimEngine.jl/issues) or ask on [discourse](https://fspm.discourse.group/c/software/virtual-plant-lab). +This is the package API for multiscale, multi-plant, soil, microclimate, and +model-scale simulations. ## Installation -To install the package, enter the Julia package manager mode by pressing `]` in the REPL, and execute the following command: +In Julia package mode: ```julia add PlantSimEngine ``` -To use the package, execute this command from the Julia REPL: +Then: ```julia using PlantSimEngine ``` -## Example usage - -The package is designed to be easy to use, and to help users avoid errors when implementing, coupling and simulating models. +## Quickstart -### Simple example +This example runs three existing toy models on one model object: -Here's a simple example of a model that simulates the growth of a plant, using a simple exponential growth model: +1. `ToyDegreeDaysCumulModel` computes daily thermal time. +2. `ToyLAIModel` consumes cumulative thermal time and computes LAI. +3. `Beer` consumes LAI and meteorology to compute absorbed PAR. ```julia -# ] add PlantSimEngine -using PlantSimEngine - -# Include the model definition from the examples sub-module: +using PlantSimEngine, PlantMeteo, Dates, DataFrames using PlantSimEngine.Examples -# Define the model: -model = ModelMapping( - ToyLAIModel(), - status=(TT_cu=1.0:2000.0,), # Pass the cumulated degree-days as input to the model +meteo_day = read_weather( + joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"); + duration=Dates.Day, ) -run!(model) # run the model - -status(model) # extract the status, i.e. the output of the model -``` - -Which gives: - -``` -TimeStepTable{Status{(:TT_cu, :LAI...}(1300 x 2): -╭─────┬────────────────┬────────────╮ -│ Row │ TT_cu │ LAI │ -│ │ Float64 │ Float64 │ -├─────┼────────────────┼────────────┤ -│ 1 │ 1.0 │ 0.00560052 │ -│ 2 │ 2.0 │ 0.00565163 │ -│ 3 │ 3.0 │ 0.00570321 │ -│ 4 │ 4.0 │ 0.00575526 │ -│ 5 │ 5.0 │ 0.00580778 │ -│ ⋮ │ ⋮ │ ⋮ │ -╰─────┴────────────────┴────────────╯ - 1295 rows omitted -``` - -> **Note** -> The `ToyLAIModel` is available from the [examples folder](https://github.com/VirtualPlantLab/PlantSimEngine.jl/tree/main/examples), and is a simple exponential growth model. It is used here for the sake of simplicity, but you can use any model you want, as long as it follows `PlantSimEngine` interface. - -Of course you can plot the outputs quite easily: - -```julia -# ] add CairoMakie -using CairoMakie - -lines(model[:TT_cu], model[:LAI], color=:green, axis=(ylabel="LAI (m² m⁻²)", xlabel="Cumulated growing degree days since sowing (°C)")) -``` - -![LAI Growth](examples/LAI_growth.png) - -### Model coupling - -Model coupling is done automatically by the package, and is based on the dependency graph between the models. To couple models, we just have to add them to the `ModelMapping`. For example, let's couple the `ToyLAIModel` with a model for light interception based on Beer's law: - -```julia -# ] add PlantSimEngine, PlantMeteo, Dates -using PlantSimEngine, PlantMeteo, Dates - -# Include the model definition from the examples folder: -using PlantSimEngine.Examples - -# Import the example meteorological data: -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -# Define the list of models for coupling: -model = ModelMapping( +model = CompositeModel( + ToyDegreeDaysCumulModel(), ToyLAIModel(), - Beer(0.6), - status=(TT_cu=cumsum(meteo_day[:, :TT]),), # Pass the cumulated degree-days as input to `ToyLAIModel`, this could also be done using another model + Beer(0.6); + environment=meteo_day, ) -``` - -The `ModelMapping` couples the models by automatically computing the dependency graph of the models. The resulting dependency graph is: -``` -╭──── Dependency graph ──────────────────────────────────────────╮ -│ ╭──── LAI_Dynamic ─────────────────────────────────────────╮ │ -│ │ ╭──── Main model ────────╮ │ │ -│ │ │ Process: LAI_Dynamic │ │ │ -│ │ │ Model: ToyLAIModel │ │ │ -│ │ │ Dep: │ │ │ -│ │ ╰────────────────────────╯ │ │ -│ │ │ ╭──── Soft-coupled model ─────────╮ │ │ -│ │ │ │ Process: light_interception │ │ │ -│ │ └──│ Model: Beer │ │ │ -│ │ │ Dep: (LAI_Dynamic = (:LAI,),) │ │ │ -│ │ ╰─────────────────────────────────╯ │ │ -│ ╰──────────────────────────────────────────────────────────╯ │ -╰────────────────────────────────────────────────────────────────╯ +sim = run!(model; steps=30, outputs=:all) +out = collect_outputs(sim; sink=DataFrame) +first(out, 6) ``` -```julia -# Run the simulation: -run!(model, meteo_day) +The compiler infers the unambiguous same-object bindings from each model's +declared inputs and outputs: `ToyLAIModel` receives `TT_cu` from +`:Degreedays`, and `Beer` receives `LAI` from `:LAI_Dynamic`. -status(model) +```julia +select( + DataFrame(explain_bindings(model)), + :application_id, + :input, + :source_application_ids, + :carrier_kind, + :copy_semantics, +) ``` -Which returns: +## Multi-Object Coupling -``` -TimeStepTable{Status{(:TT_cu, :LAI...}(365 x 3): -╭─────┬────────────────┬────────────┬───────────╮ -│ Row │ TT_cu │ LAI │ aPPFD │ -│ │ Float64 │ Float64 │ Float64 │ -├─────┼────────────────┼────────────┼───────────┤ -│ 1 │ 0.0 │ 0.00554988 │ 0.0476221 │ -│ 2 │ 0.0 │ 0.00554988 │ 0.0260688 │ -│ 3 │ 0.0 │ 0.00554988 │ 0.0377774 │ -│ 4 │ 0.0 │ 0.00554988 │ 0.0468871 │ -│ 5 │ 0.0 │ 0.00554988 │ 0.0545266 │ -│ ⋮ │ ⋮ │ ⋮ │ ⋮ │ -╰─────┴────────────────┴────────────┴───────────╯ - 360 rows omitted -``` +Use `Inputs(...)` when a model needs values from selected objects. This +model-scale LAI model reads live references to the surface of every plant in +the model: ```julia -# Plot the results: -using CairoMakie - -fig = Figure(resolution=(800, 600)) -ax = Axis(fig[1, 1], ylabel="LAI (m² m⁻²)") -lines!(ax, model[:TT_cu], model[:LAI], color=:mediumseagreen) - -ax2 = Axis(fig[2, 1], xlabel="Cumulated growing degree days since sowing (°C)", ylabel="aPPFD (mol m⁻² d⁻¹)") -lines!(ax2, model[:TT_cu], model[:aPPFD], color=:firebrick1) +plant_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, parent=:scene, + status=Status(surface=12.0)), + Object(:plant_2; scale=:Plant, kind=:plant, parent=:scene, + status=Status(surface=8.0)); + applications=( + ModelSpec(ToyLAIfromLeafAreaModel(100.0); name=:scene_lai) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :plant_surfaces => Many( + scale=:Plant, + within=SceneScope(), + var=:surface, + ), + ), + ), +) -fig +run!(plant_scene) +scene_status = only(model_objects(plant_scene; scale=:Scene)).status +(total_surface=scene_status.total_surface, LAI=scene_status.LAI) ``` -![LAI Growth and light interception](examples/LAI_growth2.png) - -### Multiscale modelling +Use `within=Self()` for plant-local aggregations, for example a plant +allocation model summing only the leaves inside the current plant. Use +`within=SceneScope()` for model-wide aggregation. -> See the Multi-scale modeling section of the docs for more details. +## Manual Calls -The package is designed to be easily scalable, and can be used to simulate models at different scales. For example, you can simulate a model at the leaf scale, and then couple it with models at any other scale, *e.g.* internode, plant, soil, scene scales. Here's an example of a simple model that simulates plant growth using sub-models operating at different scales: +Use `Calls(...)` when a parent model must directly run selected child models, +for example a model energy-balance solver that iterates leaf temperatures: ```julia -mapping = ModelMapping( - :Scene => ToyDegreeDaysCumulModel(), - :Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => :Scene, - ], - ), - Beer(0.6), - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => :Soil], - ), - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode] - ], +ModelSpec(SceneEnergyBalance(); name=:scene_energy) |> + AppliesTo(One(scale=:Scene)) |> + Calls( + :leaf_energy => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:energy_balance, ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - ), - :Internode => ( - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => :Scene,], - ), - MultiScaleModel( - model=ToyInternodeEmergence(TT_emergence=20.0), - mapped_variables=[:TT_cu => :Scene], - ), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(carbon_biomass=1.0) - ), - :Leaf => ( - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => :Scene,], + :soil => One( + kind=:soil, + scale=:Soil, + within=SceneScope(), + application=:soil_water, ), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - Status(carbon_biomass=1.0) - ), - :Soil => ( - ToySoilWaterModel(), - ), -); + ) |> + TimeStep(Hour(1)) ``` -We can import an example plant from the package: +Scenario-level `Inputs(...)` and `Calls(...)` should usually name the concrete +producer or callee with `application=...`. Use process identities in model-level +contracts such as `dep(model)`, where the model author cannot know the +application names chosen by future scenarios. -```julia -mtg = import_mtg_example() -``` +Inside the parent model, `run_call!(extra, :leaf_energy)` executes every target +and returns a vector-like collection. For iterative control, +`call_targets(extra, :leaf_energy)` returns the collection without executing +it. `run_call!(target; publish=false)` is the default for trial iterations, and +`run_call!(target; publish=true)` publishes the accepted state. -Make a fake meteorological data: +## What PlantSimEngine Handles -```julia -meteo = Weather( - [ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f=300.0), - Atmosphere(T=25.0, Wind=0.5, Rh=0.8, Ri_PAR_f=500.0) -] -); -``` +- object graphs with arbitrary plant architecture; +- several plant species and repeated plant instances through templates; +- same-rate reference wiring and typed many-object carriers; +- multirate scheduling with `Dates.Period` values; +- temporal policies such as `HoldLast`, `Interpolate`, `Integrate`, and + `Aggregate`; +- automatic global or spatial environment binding; +- mutable microclimate outputs through `meteo_outputs_`; +- growth, pruning, reparenting, and movement with binding-cache refresh; +- structured explanations for users and agents. -And run the simulation: +Useful inspection helpers include: ```julia -out_vars = ModelMapping( - :Scene => (:TT_cu,), - :Plant => (:carbon_allocation, :carbon_assimilation, :soil_water_content, :aPPFD, :TT_cu, :LAI), - :Leaf => (:carbon_demand, :carbon_allocation), - :Internode => (:carbon_demand, :carbon_allocation), - :Soil => (:soil_water_content,), -) - -out = run!(mtg, mapping, meteo, outputs=out_vars, executor=SequentialEx()); +explain_objects(model) +explain_scopes(model) +explain_bindings(model) +explain_calls(model) +explain_environment_bindings(model) +explain_schedule(model) +explain_execution_plan(model) ``` -We can then extract the outputs in a `DataFrame` and sort them: +## Documentation -```julia -using DataFrames -df_out = convert_outputs(out, DataFrame) -sort!(df_out, [:timestep, :node]) -``` +- [Stable documentation](https://VirtualPlantLab.github.io/PlantSimEngine.jl/stable) +- [Development documentation](https://VirtualPlantLab.github.io/PlantSimEngine.jl/dev) +- [CompositeModel/object quickstart](https://VirtualPlantLab.github.io/PlantSimEngine.jl/dev/composite_model/quickstart/) +- [CompositeModel/object migration guide](https://VirtualPlantLab.github.io/PlantSimEngine.jl/dev/migration_composite_model/) +- [Public API reference](https://VirtualPlantLab.github.io/PlantSimEngine.jl/dev/API/API_public/) -| **timestep**
`Int64` | **organ**
`String` | **node**
`Int64` | **carbon\_allocation**
`U{Nothing, Float64}` | **TT\_cu**
`U{Nothing, Float64}` | **carbon\_assimilation**
`U{Nothing, Float64}` | **aPPFD**
`U{Nothing, Float64}` | **LAI**
`U{Nothing, Float64}` | **soil\_water\_content**
`U{Nothing, Float64}` | **carbon\_demand**
`U{Nothing, Float64}` | -|------------------------:|----------------------:|--------------------:|-----------------------------------------------------------------------------------:|------------------------------------:|--------------------------------------------------:|-----------------------------------:|---------------------------------:|--------------------------------------------------:|--------------------------------------------:| -| 1 | Scene | 1 | | 10.0 | | | | | | -| 1 | Soil | 2 | | | | | | 0.3 | | -| 1 | Plant | 3 | | 10.0 | 0.299422 | 4.99037 | 0.00607765 | 0.3 | | -| 1 | Internode | 4 | 0.0742793 | | | | | | 0.5 | -| 1 | Leaf | 5 | 0.0742793 | | | | | | 0.5 | -| 1 | Internode | 6 | 0.0742793 | | | | | | 0.5 | -| 1 | Leaf | 7 | 0.0742793 | | | | | | 0.5 | -| 2 | Scene | 1 | | 25.0 | | | | | | -| 2 | Soil | 2 | | | | | | 0.2 | | -| 2 | Plant | 3 | | 25.0 | 0.381154 | 9.52884 | 0.00696482 | 0.2 | | -| 2 | Internode | 4 | 0.0627036 | | | | | | 0.75 | -| 2 | Leaf | 5 | 0.0627036 | | | | | | 0.75 | -| 2 | Internode | 6 | 0.0627036 | | | | | | 0.75 | -| 2 | Leaf | 7 | 0.0627036 | | | | | | 0.75 | -| 2 | Internode | 8 | 0.0627036 | | | | | | 0.75 | -| 2 | Leaf | 9 | 0.0627036 | | | | | | 0.75 | +## Projects That Use PlantSimEngine -An example output of a multiscale simulation is shown in the documentation of PlantBiophysics.jl: - -![Plant growth simulation](docs/src/www/image.png) - -### Multi-rate modelling - -PlantSimEngine also supports multi-rate MTG simulations, where different models run at different cadences inside the same execution. A typical use case is to run leaf-scale processes hourly, aggregate them into daily plant-scale balances, and then export weekly summary series from the same simulation. - -The dedicated documentation now has three pages: a short introduction to the -core ideas, a fuller step-by-step tutorial, and an advanced configuration page: - -- [Introduction to multi-rate execution](https://VirtualPlantLab.github.io/PlantSimEngine.jl/stable/multirate/introduction/) -- [Step-by-step hourly, daily, weekly simulation](https://VirtualPlantLab.github.io/PlantSimEngine.jl/stable/multirate/multirate_tutorial/) -- [Advanced multi-rate configuration](https://VirtualPlantLab.github.io/PlantSimEngine.jl/stable/multirate/advanced_configuration/) - -## Projects that use PlantSimEngine - -Take a look at these projects that use PlantSimEngine: - -- [PlantBiophysics.jl](https://github.com/VEZY/PlantBiophysics.jl) - For the simulation of biophysical processes for plants such as photosynthesis, conductance, energy fluxes, and temperature -- [XPalm](https://github.com/PalmStudio/XPalm.jl) - An experimental crop model for oil palm +- [PlantBiophysics.jl](https://github.com/VEZY/PlantBiophysics.jl) for + plant biophysical processes such as photosynthesis, conductance, energy + fluxes, and temperature. +- [XPalm](https://github.com/PalmStudio/XPalm.jl), an experimental crop model + for oil palm. ## Performance -PlantSimEngine delivers impressive performance for plant modeling tasks. On an M1 MacBook Pro, a toy model for leaf area over a year at daily time-scale took only 260 μs to perform (about 688 ns per day), and 275 μs (756 ns per day) when coupled to a light interception model. These benchmarks demonstrate performance on par with compiled languages like Fortran or C, far outpacing typical interpreted language implementations. - -For example, PlantBiophysics.jl, which implements ecophysiological models using PlantSimEngine, has been measured to run up to 38,000 times faster than equivalent implementations in other scientific computing languages. +PlantSimEngine keeps model kernels close to regular Julia functions while the +runtime handles dependency scheduling, object selection, temporal aggregation, +and environment sampling. On an M1 MacBook Pro, toy daily simulations run in +hundreds of microseconds, and PlantBiophysics.jl models using PlantSimEngine +have been measured much faster than equivalent implementations in typical +scientific scripting languages. -## Make it yours +For performance-sensitive composite models, inspect the compiled representation with +`explain_execution_plan(model)` to see homogeneous batches and concrete carrier +types. -The package is developed so anyone can easily implement plant/crop models, use it freely and as you want thanks to its MIT license. +## License And Contributions -If you develop such tools and it is not on the list yet, please make a PR or contact me so we can add it! 😃 Make sure to read the community guidelines before in case you're not familiar with such things. +PlantSimEngine is distributed under the MIT license. Questions and bug reports +are welcome on [GitHub issues](https://github.com/VirtualPlantLab/PlantSimEngine.jl/issues) +or the [FSPM discourse](https://fspm.discourse.group/c/software/virtual-plant-lab). diff --git a/benchmark/Project.toml b/benchmark/Project.toml index 16cbac02d..f5cf121fc 100644 --- a/benchmark/Project.toml +++ b/benchmark/Project.toml @@ -13,5 +13,5 @@ XPalm = "6b523e1e-d512-416c-8e51-a8fbef0064e7" [sources] PlantSimEngine = {path = ".."} -XPalm = {rev = "main", url = "https://github.com/PalmStudio/XPalm.jl"} -PlantBiophysics = {rev = "master", url = "https://github.com/VEZY/PlantBiophysics.jl"} +XPalm = {path = "../../XPalm"} +PlantBiophysics = {path = "../../PlantBiophysics"} diff --git a/benchmark/benchmarks.jl b/benchmark/benchmarks.jl index f3bdc06f7..fe0b84243 100644 --- a/benchmark/benchmarks.jl +++ b/benchmark/benchmarks.jl @@ -20,32 +20,40 @@ const SUITE = BenchmarkGroup() SUITE[suite_name] = BenchmarkGroup(["PSE", "PBP", "XPalm"]) # "PSE benchmark" -include("test-PSE-benchmark.jl") -SUITE[suite_name]["PSE"] = @benchmarkable do_benchmark_on_heavier_mtg() - -if isdefined(PlantSimEngine, :ModelSpec) # Only in new versions - include("test-multirate-buffer-benchmark.jl") - mtg_mr, mapping_mr, meteo_mr, reqs_mr, tracked_mr, nsteps_mr = setup_multirate_buffer_benchmark() - SUITE[suite_name]["PSE_multirate_status_tracked_run"] = @benchmarkable benchmark_multirate_status_tracked_run($mtg_mr, $mapping_mr, $meteo_mr, $tracked_mr, $nsteps_mr) - SUITE[suite_name]["PSE_multirate_output_request_run"] = @benchmarkable benchmark_multirate_output_request_run($mtg_mr, $mapping_mr, $meteo_mr, $reqs_mr, $tracked_mr, $nsteps_mr) -end +include(joinpath(@__DIR__, "test-PSE-benchmark.jl")) +SUITE[suite_name]["PSE"] = @benchmarkable benchmark_heavier_scene( + model, + requests, + nsteps, +) setup = ((model, requests, nsteps) = setup_heavier_model_benchmark()) + +include(joinpath(@__DIR__, "test-multirate-buffer-benchmark.jl")) +SUITE[suite_name]["PSE_multirate_retain_all_run"] = @benchmarkable benchmark_multirate_retain_all_run( + model, + nsteps, +) setup = ((model, ignored_requests, nsteps) = setup_multirate_buffer_benchmark()) +SUITE[suite_name]["PSE_multirate_output_request_run"] = @benchmarkable benchmark_multirate_output_request_run( + model, + requests, + nsteps, +) setup = ((model, requests, nsteps) = setup_multirate_buffer_benchmark()) + # "PBP benchmark" -include("test-plantbiophysics.jl") +include(joinpath(@__DIR__, "test-plantbiophysics.jl")) SUITE[suite_name]["PBP"] = @benchmarkable benchmark_plantbiophysics() - -leaf, meteo = setup_benchmark_plantbiophysics_multitimestep() -SUITE[suite_name]["PBP_multiple_timesteps_MT"] = @benchmarkable benchmark_plantbiophysics_multitimestep_MT($leaf, $meteo) -SUITE[suite_name]["PBP_multiple_timesteps_ST"] = @benchmarkable benchmark_plantbiophysics_multitimestep_ST($leaf, $meteo) +SUITE[suite_name]["PBP_batch_run"] = @benchmarkable benchmark_plantbiophysics_batch( + scenes, +) setup = (scenes = setup_benchmark_plantbiophysics_batch()) # "XPalm benchmark" -include("test-xpalm.jl") +include(joinpath(@__DIR__, "test-xpalm.jl")) SUITE[suite_name]["XPalm_setup"] = @benchmarkable xpalm_default_param_create() seconds = 120 -palm, models, out_vars, meteo = xpalm_default_param_create() -sim_outputs = xpalm_default_param_run(palm, models, out_vars, meteo) - -SUITE[suite_name]["XPalm_run"] = @benchmarkable xpalm_default_param_run(palm, models, out_vars, meteo) setup = ((palm, models, out_vars, meteo) = xpalm_default_param_create()) -SUITE[suite_name]["XPalm_convert_outputs"] = @benchmarkable xpalm_default_param_convert_outputs($sim_outputs) +SUITE[suite_name]["XPalm_run"] = @benchmarkable xpalm_default_param_run( + model, + requests, + nsteps, +) setup = ((model, requests, nsteps) = xpalm_default_param_create()) #tune!(SUITE) #results = run(SUITE, verbose=true) diff --git a/benchmark/test-PSE-benchmark.jl b/benchmark/test-PSE-benchmark.jl index 080f02b1a..029037738 100644 --- a/benchmark/test-PSE-benchmark.jl +++ b/benchmark/test-PSE-benchmark.jl @@ -18,7 +18,9 @@ ToyInternodeCrazyEmergence(; TT_emergence=300.0) = ToyInternodeCrazyEmergence(TT PlantSimEngine.inputs_(m::ToyInternodeCrazyEmergence) = (TT_cu=-Inf,) PlantSimEngine.outputs_(m::ToyInternodeCrazyEmergence) = (TT_cu_emergence=0.0,) -function PlantSimEngine.run!(m::ToyInternodeCrazyEmergence, models, status, meteo, constants=nothing, sim_object=nothing) +function PlantSimEngine.run!(m::ToyInternodeCrazyEmergence, models, status, meteo, constants=nothing, extra=nothing) + + model = runtime_model(extra) #root = get_root(status.node) @@ -28,21 +30,21 @@ function PlantSimEngine.run!(m::ToyInternodeCrazyEmergence, models, status, mete if length(MultiScaleTreeGraph.children(status.node)) == 1 && status.TT_cu - status.TT_cu_emergence >= m.TT_emergence - status_new_internode = add_organ!(status.node, sim_object, "<", :Internode, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) + status_new_internode = add_organ!(status.node, model, "<", :Internode, 2; index=1, initial_status=(carbon_biomass=1.0, TT_cu_emergence=0.0)) + add_organ!(status_new_internode.node, model, "+", :Leaf, 2; index=1, initial_status=(carbon_biomass=1.0,)) status_new_internode.TT_cu_emergence = status.TT_cu elseif (length(MultiScaleTreeGraph.children(status.node)) >= 2 && length(MultiScaleTreeGraph.children(status.node)) < 7) && status.TT_cu - status.TT_cu_emergence >= m.TT_emergence - status_new_internode = add_organ!(status.node, sim_object, "<", :Internode, 2, index=1) - add_organ!(status.node, sim_object, "+", :Leaf, 2, index=4) - add_organ!(status.node, sim_object, "+", :Leaf, 2, index=5) + status_new_internode = add_organ!(status.node, model, "<", :Internode, 2; index=1, initial_status=(carbon_biomass=1.0, TT_cu_emergence=0.0)) + add_organ!(status.node, model, "+", :Leaf, 2; index=4, initial_status=(carbon_biomass=1.0,)) + add_organ!(status.node, model, "+", :Leaf, 2; index=5, initial_status=(carbon_biomass=1.0,)) status_new_internode.TT_cu_emergence = status.TT_cu elseif (length(MultiScaleTreeGraph.children(status.node)) >= 7 && length(MultiScaleTreeGraph.children(status.node)) < 30) && status.TT_cu - status.TT_cu_emergence >= m.TT_emergence - add_organ!(status.node, sim_object, "+", :Leaf, 2, index=6) - add_organ!(status.node, sim_object, "+", :Leaf, 2, index=7) - add_organ!(status.node, sim_object, "+", :Leaf, 2, index=8) - add_organ!(status.node, sim_object, "+", :Leaf, 2, index=9) - add_organ!(status.node, sim_object, "+", :Leaf, 2, index=10) - add_organ!(status.node, sim_object, "+", :Leaf, 2, index=11) + add_organ!(status.node, model, "+", :Leaf, 2; index=6, initial_status=(carbon_biomass=1.0,)) + add_organ!(status.node, model, "+", :Leaf, 2; index=7, initial_status=(carbon_biomass=1.0,)) + add_organ!(status.node, model, "+", :Leaf, 2; index=8, initial_status=(carbon_biomass=1.0,)) + add_organ!(status.node, model, "+", :Leaf, 2; index=9, initial_status=(carbon_biomass=1.0,)) + add_organ!(status.node, model, "+", :Leaf, 2; index=10, initial_status=(carbon_biomass=1.0,)) + add_organ!(status.node, model, "+", :Leaf, 2; index=11, initial_status=(carbon_biomass=1.0,)) end @@ -50,73 +52,79 @@ function PlantSimEngine.run!(m::ToyInternodeCrazyEmergence, models, status, mete end -# Wrapped this into a function so that it doesn't plague the benchmark with variables on a global scope -#@check_allocs -function do_benchmark_on_heavier_mtg() +function _benchmark_mtg_status(node) + data = Dict{Symbol,Any}(:node => node) + scale = MultiScaleTreeGraph.symbol(node) + scale in (:Leaf, :Internode) && (data[:carbon_biomass] = 1.0) + scale == :Plant && (data[:carbon_allocation] = zeros(4)) + return Status((; data...)) +end + +function setup_heavier_model_benchmark() mtg = import_mtg_example() - # Example meteo, 365 timesteps : meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Day) - - #similar to the mtg growth test but with a much lower emergence threshold - mapping = ModelMapping( - :Scene => ToyDegreeDaysCumulModel(), - :Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => :Scene, - ], - ), - PlantSimEngine.Examples.Beer(0.6), - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - ), - :Internode => ( - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => :Scene,], + applications = ( + ModelSpec(ToyDegreeDaysCumulModel(); name=:scene_degree_days) |> + AppliesTo(One(scale=:Scene)), + ModelSpec(ToyLAIModel(); name=:plant_lai) |> + AppliesTo(Many(scale=:Plant)) |> + Inputs(:TT_cu => One(scale=:Scene, within=SceneScope(), application=:scene_degree_days, var=:TT_cu)), + ModelSpec(PlantSimEngine.Examples.Beer(0.6); name=:plant_light) |> + AppliesTo(Many(scale=:Plant)), + ModelSpec(ToyPlantRmModel(); name=:plant_rm) |> + AppliesTo(Many(scale=:Plant)) |> + Inputs(:Rm_organs => Many(scale=(:Leaf, :Internode), within=Subtree(), var=:Rm)), + ModelSpec(ToyCAllocationModel(); name=:plant_allocation) |> + AppliesTo(Many(scale=:Plant)) |> + Inputs( + :carbon_assimilation => Many(scale=:Leaf, within=Subtree(), application=:leaf_assimilation, var=:carbon_assimilation), + :carbon_demand => Many(scale=(:Leaf, :Internode), within=Subtree(), var=:carbon_demand), ), - MultiScaleModel( - model=ToyInternodeCrazyEmergence(TT_emergence=1.0), - mapped_variables=[:TT_cu => :Scene], + ModelSpec(ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0); name=:internode_demand) |> + AppliesTo(Many(scale=:Internode)) |> + Inputs(:TT => One(scale=:Scene, within=SceneScope(), application=:scene_degree_days, var=:TT)), + ModelSpec(ToyInternodeCrazyEmergence(TT_emergence=1.0); name=:internode_emergence) |> + AppliesTo(Many(scale=:Internode)) |> + Inputs(:TT_cu => One(scale=:Scene, within=SceneScope(), application=:scene_degree_days, var=:TT_cu)), + ModelSpec(ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004); name=:internode_respiration) |> + AppliesTo(Many(scale=:Internode)), + ModelSpec(ToyAssimModel(); name=:leaf_assimilation) |> + AppliesTo(Many(scale=:Leaf)) |> + Inputs( + :soil_water_content => One(scale=:Soil, within=SceneScope(), application=:soil_water, var=:soil_water_content), + :aPPFD => One(scale=:Plant, within=Ancestor(scale=:Plant), application=:plant_light, var=:aPPFD), ), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(carbon_biomass=1.0) - ), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => :Soil, :aPPFD => :Plant], - ), - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => :Scene,], - ), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - Status(carbon_biomass=1.0) - ), - :Soil => ( - ToySoilWaterModel(), - ), + ModelSpec(ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0); name=:leaf_demand) |> + AppliesTo(Many(scale=:Leaf)) |> + Inputs(:TT => One(scale=:Scene, within=SceneScope(), application=:scene_degree_days, var=:TT)), + ModelSpec(ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025); name=:leaf_respiration) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ToySoilWaterModel(); name=:soil_water) |> + AppliesTo(One(scale=:Soil)), ) - out_vars = Dict( - :Leaf => (:carbon_assimilation, :carbon_demand, :soil_water_content, :carbon_allocation), - :Internode => (:carbon_allocation, :TT_cu_emergence), - :Plant => (:carbon_allocation,), - :Soil => (:soil_water_content,), + model = CompositeModel( + mtg; + applications=applications, + environment=meteo_day, + status=_benchmark_mtg_status, ) + requests = OutputRequest[ + OutputRequest(:Leaf, :carbon_assimilation; name=:leaf_assimilation, application=:leaf_assimilation), + OutputRequest(:Leaf, :carbon_demand; name=:leaf_demand, application=:leaf_demand), + OutputRequest(:Internode, :TT_cu_emergence; name=:internode_emergence, application=:internode_emergence), + OutputRequest(:Plant, :carbon_offer; name=:plant_carbon_offer, application=:plant_allocation), + OutputRequest(:Soil, :soil_water_content; name=:soil_water, application=:soil_water), + ] + return model, requests, length(meteo_day) +end - out = run!(mtg, mapping, meteo_day, tracked_outputs=out_vars, executor=SequentialEx()) -end \ No newline at end of file +function benchmark_heavier_scene(model, requests, nsteps) + return run!(model; steps=nsteps, outputs=requests) +end + +function do_benchmark_on_heavier_mtg() + model, requests, nsteps = setup_heavier_model_benchmark() + return benchmark_heavier_scene(model, requests, nsteps) +end diff --git a/benchmark/test-multirate-buffer-benchmark.jl b/benchmark/test-multirate-buffer-benchmark.jl index 2bc67d5a5..b3e536ab6 100644 --- a/benchmark/test-multirate-buffer-benchmark.jl +++ b/benchmark/test-multirate-buffer-benchmark.jl @@ -1,6 +1,4 @@ using PlantSimEngine -using MultiScaleTreeGraph -using PlantMeteo using Dates PlantSimEngine.@process "mrbenchsource" verbose = false @@ -30,69 +28,63 @@ function PlantSimEngine.run!(::MRBenchConsumer24Model, models, status, meteo, co status.Y24 = sum(status.X) end -function _build_multirate_benchmark_mtg(nleaves::Int) - mtg = Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) - plant = Node(mtg, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - internode = Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) - - for i in 1:nleaves - Node(internode, MultiScaleTreeGraph.NodeMTG("+", :Leaf, i, 2)) - end - - return mtg +function _build_multirate_benchmark_objects(nleaves::Int) + objects = Object[ + Object(:plant; scale=:Plant), + ] + append!( + objects, + [Object(Symbol(:leaf_, i); scale=:Leaf, parent=:plant) for i in 1:nleaves], + ) + return objects end function setup_multirate_buffer_benchmark(; nleaves=2000, ndays=30) - mtg = _build_multirate_benchmark_mtg(nleaves) - - mapping = ModelMapping( - :Leaf => ( - ModelSpec(MRBenchSourceModel(Ref(0))) |> TimeStepModel(1.0), - ), - :Plant => ( - ModelSpec(MRBenchConsumer4Model()) |> - MultiScaleModel([:X => [:Leaf]]) |> - TimeStepModel(ClockSpec(4.0, 1.0)) |> - InputBindings(; X=(process=:mrbenchsource, var=:X, scale=:Leaf, policy=Integrate())), - ModelSpec(MRBenchConsumer24Model()) |> - MultiScaleModel([:X => [:Leaf]]) |> - TimeStepModel(ClockSpec(24.0, 1.0)) |> - InputBindings(; X=(process=:mrbenchsource, var=:X, scale=:Leaf, policy=Integrate())), - ), + objects = _build_multirate_benchmark_objects(nleaves) + applications = ( + ModelSpec(MRBenchSourceModel(Ref(0)); name=:hourly_source) |> + AppliesTo(Many(scale=:Leaf)) |> + TimeStep(Hour(1)), + ModelSpec(MRBenchConsumer4Model(); name=:four_hour_consumer) |> + AppliesTo(One(scale=:Plant)) |> + Inputs(:X => Many( + scale=:Leaf, + within=Subtree(), + application=:hourly_source, + var=:X, + policy=Integrate(), + window=Hour(4), + )) |> + TimeStep(Hour(4)), + ModelSpec(MRBenchConsumer24Model(); name=:daily_consumer) |> + AppliesTo(One(scale=:Plant)) |> + Inputs(:X => Many( + scale=:Leaf, + within=Subtree(), + application=:hourly_source, + var=:X, + policy=Integrate(), + window=Day(1), + )) |> + TimeStep(Day(1)), ) nsteps = 24 * ndays - meteo = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65)], nsteps)) + meteo = [(T=20.0, Wind=1.0, Rh=0.65, duration=Hour(1)) for _ in 1:nsteps] + model = CompositeModel(objects...; applications=applications, environment=meteo) reqs = [ - OutputRequest(:Leaf, :X; name=:x_hourly, process=:mrbenchsource, policy=HoldLast()), - OutputRequest(:Leaf, :X; name=:x_daily_sum, process=:mrbenchsource, policy=Integrate(), clock=ClockSpec(24.0, 1.0)), + OutputRequest(:Plant, :Y4; name=:four_hour_total, application=:four_hour_consumer), + OutputRequest(:Plant, :Y24; name=:daily_total, application=:daily_consumer), + OutputRequest(:Leaf, :X; name=:x_daily_sum, application=:hourly_source, policy=Integrate(), clock=Day(1)), ] - - tracked = Dict(:Plant => (:Y4, :Y24), :Leaf => (:X,)) - return mtg, mapping, meteo, reqs, tracked, nsteps + return model, reqs, nsteps end -function benchmark_multirate_status_tracked_run(mtg, mapping, meteo, tracked, nsteps) - run!( - mtg, - mapping, - meteo, - nsteps=nsteps, - check=true, - executor=SequentialEx(), - tracked_outputs=tracked - ) +function benchmark_multirate_retain_all_run(model, nsteps) + return run!(model; steps=nsteps, outputs=:all) end -function benchmark_multirate_output_request_run(mtg, mapping, meteo, reqs, tracked, nsteps) - run!( - mtg, - mapping, - meteo, - nsteps=nsteps, - check=true, - executor=SequentialEx(), - tracked_outputs=reqs - ) +function benchmark_multirate_output_request_run(model, reqs, nsteps) + return run!(model; steps=nsteps, outputs=reqs) end diff --git a/benchmark/test-plantbiophysics.jl b/benchmark/test-plantbiophysics.jl index 83b6f0c21..bbe49f4f4 100644 --- a/benchmark/test-plantbiophysics.jl +++ b/benchmark/test-plantbiophysics.jl @@ -1,182 +1,76 @@ -# For local testing : -#using Pkg -#Pkg.develop("PlantSimEngine") -#using PlantSimEngine - -using Pkg -#Pkg.add(url="https://github.com/VEZY/PlantBiophysics.jl#dev") -#Pkg.instantiate() -using Statistics -#using DataFrames -#using CSV -using Random +using Dates +using DataFrames using PlantBiophysics -#using BenchmarkTools -#using Test -#using PlantMeteo - -function benchmark_plantbiophysics() - - Random.seed!(1) # Set random seed - microbenchmark_steps = 100 # Number of times the microbenchmark is run - microbenchmark_evals = 1 # N. times each sample is run to be sure of the output - N = 100 # Number of timesteps simulated for each microbenchmark step - - length_range = 10000 - Ra_SW_f = range(10, 500, length=length_range) - Ta = range(18, 40, length=length_range) - Wind = range(0.5, 20, length=length_range) - P = range(90, 101, length=length_range) - Rh = range(0.1, 0.98, length=length_range) - Ca = range(360, 900, length=length_range) - skyF = range(0.0, 1.0, length=length_range) - d = range(0.001, 0.5, length=length_range) - Jmax = range(200.0, 300.0, length=length_range) - Vmax = range(150.0, 250.0, length=length_range) - Rd = range(0.3, 2.0, length=length_range) - TPU = range(5.0, 20.0, length=length_range) - g0 = range(0.001, 2.0, length=length_range) - g1 = range(0.5, 15.0, length=length_range) - vars = hcat([Ta, Wind, P, Rh, Ca, Jmax, Vmax, Rd, Ra_SW_f, skyF, d, TPU, g0, g1]) - - set = [rand.(vars) for i = 1:N] - set = reshape(vcat(set...), (length(set[1]), length(set)))' - name = [ - "T", - "Wind", - "P", - "Rh", - "Ca", - "JMaxRef", - "VcMaxRef", - "RdRef", - "Ra_SW_f", - "sky_fraction", - "d", - "TPURef", - "g0", - "g1", - ] - set = DataFrame(set, name) - @. set[!, :vpd] = e_sat(set.T) - vapor_pressure(set.T, set.Rh) - @. set[!, :aPPFD] = set.Ra_SW_f * 0.48 * 4.57 - - constants = Constants() - #time_PB = Vector{Float64}(undef, N*microbenchmark_steps) - for i = 1:N - leaf = ModelMapping( - energy_balance=Monteith(), - photosynthesis=Fvcb( - VcMaxRef=set.VcMaxRef[i], - JMaxRef=set.JMaxRef[i], - RdRef=set.RdRef[i], - TPURef=set.TPURef[i], - ), - stomatal_conductance=Medlyn(set.g0[i], set.g1[i]), - status=( - Ra_SW_f=set.Ra_SW_f[i], - sky_fraction=set.sky_fraction[i], - aPPFD=set.aPPFD[i], - d=set.d[i], - ), - ) - #deps = PlantSimEngine.dep(leaf) - meteo = Atmosphere(T=set.T[i], Wind=set.Wind[i], P=set.P[i], Rh=set.Rh[i], Cₐ=set.Ca[i]) - #st = PlantMeteo.row_struct(leaf.status[1]) - #b_PB = @benchmark run!($leaf, $meteo, $constants, nothing; executor = ThreadedEx()) evals = microbenchmark_evals samples = microbenchmark_steps - run!(leaf, meteo, constants, nothing; executor=ThreadedEx()) +using PlantMeteo +using Random - # transform in seconds - #=for j in 1:microbenchmark_steps - time_PB[microbenchmark_steps*(i-1) + j] = b_PB.times[j]*1e-9 - end=# - end - #return time_PB +function _plantbiophysics_forcing_set(n::Int) + Random.seed!(1) + length_range = 10_000 + ranges = ( + T=range(18, 40; length=length_range), + Wind=range(0.5, 20; length=length_range), + P=range(90, 101; length=length_range), + Rh=range(0.1, 0.98; length=length_range), + Ca=range(360, 900; length=length_range), + JMaxRef=range(200.0, 300.0; length=length_range), + VcMaxRef=range(150.0, 250.0; length=length_range), + RdRef=range(0.3, 2.0; length=length_range), + Ra_SW_f=range(10, 500; length=length_range), + sky_fraction=range(0.0, 1.0; length=length_range), + d=range(0.001, 0.5; length=length_range), + TPURef=range(5.0, 20.0; length=length_range), + g0=range(0.001, 2.0; length=length_range), + g1=range(0.5, 15.0; length=length_range), + ) + columns = (; ( + name => [rand(values) for _ in 1:n] + for (name, values) in pairs(ranges) + )...) + return DataFrame(columns) end -function setup_benchmark_plantbiophysics_multitimestep() - - Random.seed!(1) # Set random seed - N = 100 # Number of timesteps simulated for each microbenchmark step - - length_range = 10000 - Ra_SW_f = range(10, 500, length=length_range) - Ta = range(18, 40, length=length_range) - Wind = range(0.5, 20, length=length_range) - P = range(90, 101, length=length_range) - Rh = range(0.1, 0.98, length=length_range) - Ca = range(360, 900, length=length_range) - skyF = range(0.0, 1.0, length=length_range) - d = range(0.001, 0.5, length=length_range) - Jmax = range(200.0, 300.0, length=length_range) - Vmax = range(150.0, 250.0, length=length_range) - Rd = range(0.3, 2.0, length=length_range) - TPU = range(5.0, 20.0, length=length_range) - g0 = range(0.001, 2.0, length=length_range) - g1 = range(0.5, 15.0, length=length_range) - vars = hcat([Ta, Wind, P, Rh, Ca, Jmax, Vmax, Rd, Ra_SW_f, skyF, d, TPU, g0, g1]) - - set = [rand.(vars) for i = 1:N] - set = reshape(vcat(set...), (length(set[1]), length(set)))' - name = [ - "T", - "Wind", - "P", - "Rh", - "Ca", - "JMaxRef", - "VcMaxRef", - "RdRef", - "Ra_SW_f", - "sky_fraction", - "d", - "TPURef", - "g0", - "g1", - ] - set = DataFrame(set, name) - @. set[!, :vpd] = e_sat(set.T) - vapor_pressure(set.T, set.Rh) - @. set[!, :aPPFD] = set.Ra_SW_f * 0.48 * 4.57 - - leaf = Vector{ModelMapping}(undef, N) - for i = 1:N - leaf[i] = ModelMapping( - energy_balance=Monteith(), - photosynthesis=Fvcb( - VcMaxRef=set.VcMaxRef[i], - JMaxRef=set.JMaxRef[i], - RdRef=set.RdRef[i], - TPURef=set.TPURef[i], - ), - stomatal_conductance=Medlyn(set.g0[i], set.g1[i]), - status=( - Ra_SW_f=set.Ra_SW_f, - sky_fraction=set.sky_fraction, - aPPFD=set.aPPFD, - d=set.d, - ), - ) - end - - atm = Vector{Atmosphere}(undef, N) - for i in 1:N - atm[i] = Atmosphere(T=set.T[i], Wind=set.Wind[i], P=set.P[i], Rh=set.Rh[i], Cₐ=set.Ca[i]) - end - meteo = Weather(atm) +function _plantbiophysics_leaf_scene(row) + return PlantBiophysics.leaf_scene( + Monteith(), + Fvcb( + VcMaxRef=row.VcMaxRef, + JMaxRef=row.JMaxRef, + RdRef=row.RdRef, + TPURef=row.TPURef, + ), + Medlyn(row.g0, row.g1); + status=Status( + Ra_SW_f=row.Ra_SW_f, + sky_fraction=row.sky_fraction, + aPPFD=row.Ra_SW_f * 0.48 * 4.57, + d=row.d, + ), + environment=Atmosphere( + T=row.T, + Wind=row.Wind, + P=row.P, + Rh=row.Rh, + Cₐ=row.Ca, + duration=Hour(1), + ), + ) +end - return leaf, meteo +function setup_benchmark_plantbiophysics_batch(; n=100) + forcing = _plantbiophysics_forcing_set(n) + return [_plantbiophysics_leaf_scene(row) for row in eachrow(forcing)] end -function benchmark_plantbiophysics_multitimestep_MT(leaf, meteo) - N = length(meteo) - for i in 1:N - run!(leaf[i], meteo, Constants(), nothing; executor=ThreadedEx()) +function benchmark_plantbiophysics_batch(scenes) + constants = Constants() + for model in scenes + run!(model; constants=constants, outputs=:none) end + return nothing end -function benchmark_plantbiophysics_multitimestep_ST(leaf, meteo) - N = length(meteo) - for i in 1:N - run!(leaf[i], meteo, Constants(), nothing; executor=SequentialEx()) - end -end \ No newline at end of file +function benchmark_plantbiophysics() + scenes = setup_benchmark_plantbiophysics_batch() + return benchmark_plantbiophysics_batch(scenes) +end diff --git a/benchmark/test-xpalm.jl b/benchmark/test-xpalm.jl index e3d9f7c98..c2bd903d1 100644 --- a/benchmark/test-xpalm.jl +++ b/benchmark/test-xpalm.jl @@ -1,60 +1,77 @@ -#using Pkg -#Pkg.develop("PlantSimEngine") -#using PlantSimEngine - -using Pkg -#Pkg.add(url="https://github.com/PalmStudio/XPalm.jl#dev") -#Pkg.instantiate() -using Test -using PlantMeteo#, MultiScaleTreeGraph -#using CairoMakie, AlgebraOfGraphics -using DataFrames, CSV, Statistics +using BenchmarkTools +using CSV +using DataFrames using Dates +using PlantSimEngine using XPalm -using BenchmarkTools + +function _xpalm_output_requests(model, vars) + applications = explain_applications(model) + requests = OutputRequest[] + for (scale, variables) in pairs(vars) + for variable in variables + scale_symbol = Symbol(scale) + variable_symbol = Symbol(variable) + candidates = [ + row.application_id + for row in applications + if ( + scale_symbol in row.target_scales || + object_address(row.applies_to).scale == scale_symbol + ) && variable_symbol in row.outputs + ] + isempty(candidates) && error( + "No XPalm benchmark output publisher for `$(scale_symbol).$(variable_symbol)`.", + ) + push!( + requests, + OutputRequest( + scale_symbol, + variable_symbol; + name=Symbol(scale, "__", variable), + application=last(candidates), + ), + ) + end + end + return requests +end function xpalm_default_param_create() - meteo = CSV.read(joinpath(dirname(dirname(pathof(XPalm))), "0-data", "meteo.csv"), DataFrame) - #meteo.duration = [Dates.Day(i[1:1]) for i in meteo.duration] - m = Weather(meteo) + meteo = CSV.read( + joinpath(dirname(dirname(pathof(XPalm))), "0-data", "meteo.csv"), + DataFrame, + ) + :duration in propertynames(meteo) || + (meteo.duration = fill(Day(1), nrow(meteo))) - out_vars = Dict{Symbol,Any}( + vars = Dict{Symbol,Any}( :Scene => (:lai,), - # :Scene => (:LAI, :scene_leaf_area, :aPPFD, :TEff), - # :Plant => (:plant_age, :ftsw, :newPhytomerEmergence, :aPPFD, :plant_leaf_area, :carbon_assimilation, :carbon_offer_after_rm, :Rm, :TT_since_init, :TEff, :phytomer_count, :newPhytomerEmergence), - :Leaf => (:Rm, :potential_area, :TT_since_init, :TEff, :biomass, :carbon_demand, :carbon_allocation,), - # :Leaf => (:Rm, :potential_area), - # :Internode => (:Rm, :carbon_allocation, :carbon_demand), + :Leaf => ( + :Rm, + :potential_area, + :TT_since_init, + :biomass, + :carbon_demand, + ), :Male => (:Rm,), - # :Female => (:biomass,), - # :Soil => (:TEff, :ftsw, :root_depth), ) - - # Example 1: Run the model with the default parameters (but output as a DataFrame): - palm = XPalm.Palm(initiation_age=0, parameters=XPalm.default_parameters()) - models = XPalm.model_mapping(palm) - return palm, models, out_vars, m + palm = XPalm.Palm( + initiation_age=0, + parameters=XPalm.default_parameters(), + ) + model = XPalm.xpalm_scene(palm; environment=meteo) + return model, _xpalm_output_requests(model, vars), nrow(meteo) end -function xpalm_default_param_run(palm, models, out_vars, meteo) - sim_outputs = PlantSimEngine.run!(palm.mtg, models, meteo, tracked_outputs=out_vars, executor=PlantSimEngine.SequentialEx(), check=false) - return sim_outputs +function xpalm_default_param_run(model, requests, nsteps) + return PlantSimEngine.run!( + model; + steps=nsteps, + outputs=requests, + ) end -function xpalm_default_param_convert_outputs(sim_outputs) - df = PlantSimEngine.convert_outputs(sim_outputs, DataFrame, no_value=missing) - return df +function xpalm_default_param_collect_outputs(simulation) + return PlantSimEngine.collect_outputs(simulation; sink=DataFrame) end - - -println(Pkg.status("XPalm")) - -#=@testset "XPalm simple test" begin - # default number of seconds is 5 - b_XP = @benchmark xpalm_default_param_run() seconds = 120 - - #N = length(b_XP.times) - - @test mean(b_XP.times*1e-9) > 10 - @test mean(b_XP.times*1e-9) < 15 -end =# \ No newline at end of file diff --git a/benchmark/test/runtests.jl b/benchmark/test/runtests.jl new file mode 100644 index 000000000..0a35bf552 --- /dev/null +++ b/benchmark/test/runtests.jl @@ -0,0 +1,39 @@ +using CSV +using DataFrames +using Dates +using MultiScaleTreeGraph +using PlantMeteo +using PlantSimEngine +using PlantSimEngine.Examples +using Statistics +using Test + +@testset "PlantSimEngine benchmark API smoke" begin + include(joinpath(@__DIR__, "..", "test-PSE-benchmark.jl")) + model, requests, _ = setup_heavier_model_benchmark() + simulation = benchmark_heavier_scene(model, requests, 1) + @test current_step(simulation) == 1 + @test !isempty(collect_outputs(simulation; sink=nothing)) +end + +@testset "multirate benchmark API smoke" begin + include(joinpath(@__DIR__, "..", "test-multirate-buffer-benchmark.jl")) + model, requests, nsteps = setup_multirate_buffer_benchmark(; ndays=1, nleaves=4) + simulation = benchmark_multirate_output_request_run(model, requests, nsteps) + @test current_step(simulation) == nsteps + @test !isempty(collect_outputs(simulation; sink=nothing)) +end + +@testset "PlantBiophysics benchmark API smoke" begin + include(joinpath(@__DIR__, "..", "test-plantbiophysics.jl")) + scenes = setup_benchmark_plantbiophysics_batch(; n=2) + @test isnothing(benchmark_plantbiophysics_batch(scenes)) +end + +@testset "XPalm benchmark API smoke" begin + include(joinpath(@__DIR__, "..", "test-xpalm.jl")) + model, requests, _ = xpalm_default_param_create() + simulation = PlantSimEngine.run!(model; steps=1, outputs=requests) + @test current_step(simulation) == 1 + @test !isempty(xpalm_default_param_collect_outputs(simulation)) +end diff --git a/docs/make.jl b/docs/make.jl index 7c3dd6a01..aab2c7aee 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -5,6 +5,27 @@ using PlantMeteo using DataFrames, CSV using Documenter using CairoMakie +using PlantSimEngine.Examples + +function build_model_graph_example() + output_dir = joinpath(@__DIR__, "src", "assets") + mkpath(output_dir) + model = PlantSimEngine.CompositeModel( + ToyDegreeDaysCumulModel(), + ToyLAIModel(), + Beer(0.6); + status=(TT=12.0,), + id=:plant, + scale=:Plant, + kind=:plant, + ) + write_model_graph_view( + joinpath(output_dir, "model_graph_example.html"), + model, + ) +end + +build_model_graph_example() DocMeta.setdocmeta!(PlantSimEngine, :DocTestSetup, :(using PlantSimEngine, PlantMeteo, DataFrames, CSV, CairoMakie); recursive=true) @@ -30,61 +51,80 @@ makedocs(; "Key Concepts" => "./prerequisites/key_concepts.md", "Julia language basics" => "./prerequisites/julia_basics.md", ], - "Step by step - Single-scale simulations" => [ + "Getting Started" => [ + "Quickstart" => "./composite_model/quickstart.md", "Detailed first simulation" => "./step_by_step/detailed_first_example.md", - "Coupling" => "./step_by_step/simple_model_coupling.md", + "Port an existing model" => "./guides/modelers/port_existing_model.md", + "Migrating from mappings" => "migration_composite_model.md", + ], + "Building Scenarios" => [ + "Coupling models" => "./guides/coupling.md", + "Visualize and edit a composite model" => "./guides/graph_visualizer_editor.md", "Model Switching" => "./step_by_step/model_switching.md", - "Quick examples" => "./step_by_step/quick_and_dirty_examples.md", "Implementing a process" => "./step_by_step/implement_a_process.md", "Implementing a model" => "./step_by_step/implement_a_model.md", - "Parallelization" => "./step_by_step/parallelization.md", "Advanced coupling and hard dependencies" => "./step_by_step/advanced_coupling.md", "Implementing a model : additional notes" => "./step_by_step/implement_a_model_additional.md", ], - "Execution" => "model_execution.md", - "Model traits" => "model_traits.md", - "AI agent skill" => "agent_skill.md", - "Working with data" => [ - "Reducing DoF" => "./working_with_data/reducing_dof.md", - "Fitting" => "./working_with_data/fitting.md", - "Input types" => "./working_with_data/inputs.md", - "Visualizing outputs and data" => "./working_with_data/visualising_outputs.md", - "Floating-point considerations" => "./working_with_data/floating_point_accumulation_error.md", + "Multiscale Composite Models" => [ + "How composite models execute" => "./guides/multiscale/concepts.md", + "From one object" => "./guides/multiscale/from_one_object.md", + "Value coupling" => "./guides/multiscale/value_coupling.md", + "Importing an MTG" => "./guides/multiscale/import_mtg.md", + "Manual calls" => "./guides/multiscale/manual_calls.md", + "Visualizing structure" => "./guides/multiscale/visualizing_structure.md", + ], + "Growing Plant Tutorial" => [ + "Part 1: growth" => "./tutorials/growing_plant/part1_growth.md", + "Part 2: roots and water" => "./tutorials/growing_plant/part2_roots_water.md", + "Part 3: debugging" => "./tutorials/growing_plant/part3_debugging.md", ], - "Moving to multiscale" => [ - "Multiscale considerations" => "./multiscale/multiscale_considerations.md", - "Converting a simulation to multi-scale" => "./multiscale/single_to_multiscale.md", - "More variable mapping examples" => "./multiscale/multiscale.md", - "Handling cyclic dependencies" => "./multiscale/multiscale_cyclic.md", - "Multiscale coupling considerations" => "./multiscale/multiscale_coupling.md", - "Building a simple plant" => [ - "A rudimentary plant simulation" => "./multiscale/multiscale_example_1.md", - "Expanding the plant simulation" => "./multiscale/multiscale_example_2.md", - "Fixing bugs in the plant simulation" => "./multiscale/multiscale_example_3.md", - ], - "Visualizing our toy plant with PlantGeom" => "./multiscale/multiscale_example_4.md", + "Time And Environment" => [ + "Understanding cadence" => "./guides/time/multirate_concepts.md", + "Hourly, daily, and weekly" => "./guides/time/hourly_daily_weekly.md", + "Advanced configuration" => "./guides/time/advanced_time_environment.md", ], - "Multi-rate tutorials" => [ - "Introduction to multi-rate execution" => "./multirate/introduction.md", - "Step-by-step hourly/daily/weekly simulation" => "./multirate/multirate_tutorial.md", - "Advanced multi-rate configuration" => "./multirate/advanced_configuration.md", + "Data And Analysis" => [ + "Environment inputs" => "./guides/data/environment_inputs.md", + "Collecting and plotting outputs" => "./guides/data/outputs_plotting.md", + "Forcing observations" => "./guides/data/forcing_observations.md", + "Numerical reliability" => "./guides/data/numerical_reliability.md", + "Parameter fitting" => "./working_with_data/fitting.md", ], - "Troubleshooting and testing" => [ - "Troubleshooting" => "./troubleshooting_and_testing/plantsimengine_and_julia_troubleshooting.md", - "Automated testing" => "./troubleshooting_and_testing/downstream_tests.md", - "Tips and Workarounds" => "./troubleshooting_and_testing/tips_and_workarounds.md", - "Implicit contracts" => "./troubleshooting_and_testing/implicit_contracts.md", - ], "API" => [ + "Troubleshooting" => [ + "Common errors" => "./troubleshooting/common_errors.md", + "Runtime contracts" => "./troubleshooting/runtime_contracts.md", + "Dependency cycles" => "./troubleshooting/dependency_cycles.md", + "State and repeated updates" => "./guides/modelers/stateful_models.md", + "Downstream testing" => "./troubleshooting_and_testing/downstream_tests.md", + ], + "Model execution" => "model_execution.md", + "Model traits" => "model_traits.md", + "AI agent skill" => "agent_skill.md", + "API" => [ "Public API" => "./API/API_public.md", + "Public symbol inventory" => "./API/public_symbols.md", "Example models" => "./API/API_examples.md", "Internal API" => "./API/API_private.md",], + "Development designs" => [ + "Public API refinement decisions" => "./dev/public_api_refinement_decisions.md", + "Public API refinement completion audit" => "./dev/public_api_refinement_completion_audit.md", + "Composite model/object design" => "./dev/composite_model_design.md", + "Composite model/object implementation plan" => "./dev/composite_model_implementation_plan.md", + "Composite model/object completion audit" => "./dev/composite_model_completion_audit.md", + "MAESPA-style composite-model example handoff" => "./dev/maespa_model_handoff.md", + "Code cleanup audit" => "./dev/code_cleanup_audit.md", + "Release notes handoff" => "./dev/release_notes_handoff.md", + ], "Developer guidelines" => "developers.md", "Roadmap" => "planned_features.md", ] ) -deploydocs(; - repo="github.com/VirtualPlantLab/PlantSimEngine.jl.git", - devbranch="main", - push_preview=true, # Visit https://VirtualPlantLab.github.io/PlantSimEngine.jl/previews/PR128 to visualize the preview of the PR #128 -) +if get(ENV, "PLANTSIMENGINE_DOCS_BUILD_ONLY", "false") != "true" + deploydocs(; + repo="github.com/VirtualPlantLab/PlantSimEngine.jl.git", + devbranch="main", + push_preview=true, # Visit https://VirtualPlantLab.github.io/PlantSimEngine.jl/previews/PR128 to visualize the preview of the PR #128 + ) +end diff --git a/docs/paper/paper.md b/docs/paper/paper.md index 974dec5a0..29f2b3a82 100644 --- a/docs/paper/paper.md +++ b/docs/paper/paper.md @@ -31,7 +31,8 @@ bibliography: paper.bib - Switch between models without changing any code, with a simple syntax to define the model to use for a given process - Reduce the degrees of freedom by fixing variables, passing measurements, or using a simpler model for a given process - Fast computation, with 100th of nanoseconds for one model, two coupled models (see this [benchmark script](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples/benchmark.jl)), or the full energy balance of a leaf using [PlantBiophysics.jl](https://github.com/VEZY/PlantBiophysics.jl) [@vezy_vezyplantbiophysicsjl_2023], a package that uses PlantSimEngine -- Out of the box sequential, parallel (multi-threaded) or distributed (multi-process) computations over objects, time-steps and independent processes (thanks to [Floops.jl](https://juliafolds.github.io/FLoops.jl/stable/)) +- Sequential scene execution through concrete application/object batches. A + public parallel or distributed executor is not currently part of the API. - Easily scalable, with methods for computing over objects, time-steps and even [Multi-Scale Tree Graphs](https://github.com/VEZY/MultiScaleTreeGraph.jl) [@vezy_multiscaletreegraphjl_2023] - Composable, allowing the use of any types as inputs such as [Unitful](https://github.com/PainterQubits/Unitful.jl) to propagate units, or [MonteCarloMeasurements.jl](https://github.com/baggepinnen/MonteCarloMeasurements.jl) [@carlson_montecarlomeasurementsjl_2020] to propagate measurement error diff --git a/docs/src/API/API_public.md b/docs/src/API/API_public.md index b36380e4c..49eb313a8 100644 --- a/docs/src/API/API_public.md +++ b/docs/src/API/API_public.md @@ -1,165 +1,128 @@ # Public API +## Unified CompositeModel/Object API + +### Scenario and model applications + +- `CompositeModel` stores objects, model applications, instances, and environment. +- `CompositeModel(model, models...; status=..., timestep=...)` is the concise one-object + form and lowers to the same object/application representation. +- `Object` represents one runtime entity with stable identity and status. +- `CompositeModelTemplate` and `ObjectInstance` reuse a model across instances. +- `ModelSpec(model; name=...)` identifies one model application. +- `AppliesTo(...)` selects its target objects. + +### Coupling + +- `Inputs(...)` declares value dependencies. +- `Calls(...)` declares manually executable child models. +- `Updates(:variable; after=:application_id)` orders intentional duplicate writers. +- `Input(...)` and `Call(...)` express model defaults through `dep(model)`. +- `run_call!(extra, :name; publish=false)` executes every resolved hard-call + target and always returns a vector-like `CallTargets` collection. +- `call_targets(extra, :name)` returns the same non-executing collection for + fine-grained execution with `run_call!(target; ...)`. + +### Selectors + +- Multiplicity: `One(...)`, `OptionalOne(...)`, and `Many(...)`. +- Scope: `SceneScope()`, `Self()`, `Subtree()`, `SelfPlant()`, + `Ancestor(...)`, and `Scope(name)`. +- Labels and topology: `Kind(...)`, `Species(...)`, `Scale(...)`, and + `Relation(...)`. + +### Time and environment + +- `TimeStep(period)` sets an application cadence. +- `HoldLast`, `Interpolate`, `Integrate`, and `Aggregate` define temporal + input policies. +- `Environment(...)` configures environment providers and source remapping. +- Models declare environment variables with `meteo_inputs_` and + `meteo_outputs_`. +- `OutputRequest(selector, variable; ...)` selects retained and optionally + resampled streams using the same object selector grammar. + +### Lifecycle + +- `objects_from_mtg` and `CompositeModel(mtg; ...)` adapt an MTG into the object + registry. +- `add_organ!` creates and initializes a new organ in an MTG-backed model. +- `runtime_model(extra)` gives lifecycle-capable kernels sanctioned access to + the live model from their `RunContext`. +- `register_object!`, `remove_object!`, and `reparent_object!` change + topology. +- `move_object!` and `update_geometry!` change spatial state. +- Supported lifecycle operations automatically invalidate and refresh the + affected structural or spatial bindings before the next timestep. +- `run!(model; steps=..., outputs=:none)` starts a fresh result timeline and + returns a `Simulation`. +- `continue!(simulation; steps=...)` and `step!(simulation)` advance an + existing timeline without resetting temporal state. +- `current_step(simulation)` reports the accepted timeline position. +- `collect_outputs(sim)` materializes retained output streams. + +### Explanations + +Use structured explanation helpers instead of inspecting internals: + +- `explain_objects` +- `explain_instances` +- `explain_scopes` +- `explain_applications` +- `explain_bindings` +- `explain_calls` +- `explain_environment_bindings` +- `explain_schedule` +- `explain_writers` +- `explain_model_bundles` +- `explain_execution_plan` +- `explain_output_retention` +- `explain_outputs` +- `explain_initialization` + +See [Migrating To The CompositeModel/Object API](../migration_composite_model.md) for +translations from removed APIs. + +### CompositeModel graph visualization and editing + +- `compile_model_report(model; strict=false)` preserves partial graph state and + structured diagnostics for incomplete or cyclic composite models. +- `model_graph_view(model; level=:applications)` returns the typed graph view. +- `model_graph_view_json(model)` serializes the same DTO used by the browser. +- `write_model_graph_view(path, model)` writes a self-contained static viewer. +- `edit_graph(model)` starts the optional HTTP editor after `using HTTP`. +- `current_model(session)`, `undo!(session)`, `redo!(session)`, and + `close(session)` control an interactive session from Julia. + +See [Visualize And Edit A CompositeModel](../guides/graph_visualizer_editor.md) for the +runnable workflow, model discovery, selector previews, cycle breaking, and +Documenter embedding. + +## Advanced compiler API + +```@docs +PlantSimEngine.Advanced +``` + +Compiler representations, cache refresh operations, and low-level binding +compilers live under `PlantSimEngine.Advanced`. They are intended for package +integration, diagnostics development, and compiler work rather than ordinary +scenario composition. Prefer the public `explain_*` functions, which accept a +`CompositeModel` directly, over manually compiling and inspecting fields. + +Examples include `Advanced.compile_composite_model`, `Advanced.refresh_bindings!`, and +the `Advanced.CompiledCompositeModel` family. These qualified APIs may evolve more +quickly than the default modeling interface. + ## Index ```@index Pages = ["API_public.md"] ``` -## API documentation +## API Documentation ```@autodocs Modules = [PlantSimEngine] Private = false ``` - -## Multi-rate policy examples - -For mapping-level multi-rate configuration, combine: - -- `ModelSpec(...)` -- `TimeStepModel(...)` -- `InputBindings(...)` -- `MeteoBindings(...)` -- `MeteoWindow(...)` -- `OutputRouting(...)` -- `ScopeModel(...)` -- `timespec(::Type{<:AbstractModel})` (optional trait) -- `output_policy(::Type{<:AbstractModel})` (optional trait) -- `timestep_hint(::Type{<:AbstractModel})` (optional trait) -- `meteo_hint(::Type{<:AbstractModel})` (optional trait) -- `resolved_model_specs(mapping)` (utility) -- `explain_model_specs(mapping_or_sim)` (utility) -- `OutputRequest(...)` in `tracked_outputs` for resampled exports - -`TimeStepModel(...)` accepts: -- `Real` step counts -- `ClockSpec` -- fixed `Dates` periods (`Dates.Second`, `Dates.Minute`, `Dates.Hour`, `Dates.Day`, ...) - -Period conversion detail: -- Period-based timesteps are converted using the meteo base step `duration`. -- Example: `TimeStepModel(Dates.Day(1))` with hourly meteo (`Dates.Hour(1)`) maps to `ClockSpec(24.0, 1.0)`, - so execution times are `t = 1, 25, 49, ...`. - -Trait-based inference detail: -- If `TimeStepModel(...)` is omitted, runtime resolves timestep from: -: `timespec(model)` when non-default, otherwise meteo `duration`. -- `timestep_hint(::Type{<:Model})` is then interpreted as: -: `required` = hard compatibility constraint, `preferred` = informational only. -- If `InputBindings(...)` is omitted, same-name sources are inferred automatically from -: unique producers (same scale first, then cross-scale). Ambiguous cases require explicit bindings. -- For inferred bindings, policy defaults to producer `output_policy` when defined, otherwise `HoldLast()`. -- Explicit `InputBindings(..., policy=...)` always overrides trait defaults. -- `output_policy` is hint-only: it is applied only when an output is actually consumed/exported. -- If `MeteoBindings(...)` / `MeteoWindow(...)` are omitted, `meteo_hint(::Type{<:Model})` -: may provide `(; bindings=..., window=...)`. -- Explicit mapping-level configuration always overrides hints. - -Compatibility checks: -- Meteo `duration` is mandatory when meteo is provided. -- For models with meteo-derived timestep, runtime enforces `timestep_hint.required`. -- `timestep_hint.preferred` never sets runtime timestep by itself. - -Scope selection detail: -- `ScopeModel(:global)` is the default and shares streams across the whole simulation. -- `ScopeModel(:plant)` isolates streams within each plant subtree. -- `ScopeModel(:scene)` isolates by scene ancestor. -- `ScopeModel(:self)` isolates by node id. - -### Exporting variables at requested rates - -```julia -req_hold = OutputRequest(:Leaf, :A; name=:A_hourly, process=:assim, policy=HoldLast()) -req_day = OutputRequest(:Leaf, :A; name=:A_daily_sum, process=:assim, policy=Integrate(), clock=ClockSpec(24.0, 1.0)) -run!(sim, meteo; tracked_outputs=[req_hold, req_day], executor=SequentialEx()) -out = collect_outputs(sim; sink=DataFrame) - -# or directly: -out_status, out = run!( - sim, - meteo; - tracked_outputs=[req_hold, req_day], - return_requested_outputs=true, -) -``` - -- `process` is optional when the source is canonical and unique. -- `policy` defines how source streams are resampled at export time. -- `clock` defines the export schedule; omit it to export every simulation step. - -### Default hold-last - -```julia -ModelSpec(ConsumerModel()) |> -TimeStepModel(ClockSpec(2.0, 1.0)) |> -InputBindings(; x=(process=:producer, var=:x)) -``` - -### Meteo aggregation bindings - -```julia -ModelSpec(DailyModel()) |> -TimeStepModel(ClockSpec(24.0, 1.0)) |> -MeteoWindow(CalendarWindow(:day; anchor=:current_period, week_start=1, completeness=:strict)) |> -MeteoBindings( - T=MeanWeighted(), # default source is :T - Ri_SW_f=RadiationEnergy(), # integrate W m-2 to MJ m-2 over the model window - custom_peak=(source=:custom_var, reducer=MaxReducer()), -) -``` - -`MeteoWindow(...)` options: -- `RollingWindow()` (default): trailing rolling window driven by `dt`. -- `CalendarWindow(period; anchor, week_start, completeness)` with: -: `period` in `:day`, `:week`, `:month` -: `anchor` in `:current_period`, `:previous_complete_period` -: `week_start` in `1:7` (1 = Monday) -: `completeness` in `:allow_partial`, `:strict` - -### Parameterized window reducers - -`Integrate()` defaults to `SumReducer()`; `Aggregate()` defaults to `MeanReducer()`. -With the same reducer, they are runtime-equivalent. -Use `Integrate` for accumulation semantics and `Aggregate` for summary-statistics semantics. - -```julia -ModelSpec(DailyModel()) |> -TimeStepModel(ClockSpec(24.0, 1.0)) |> -InputBindings(; a=(process=:hourly_assim, var=:A, scale=:Leaf, policy=Integrate(SumReducer()))) - -ModelSpec(DailyModel()) |> -TimeStepModel(ClockSpec(24.0, 1.0)) |> -InputBindings(; a=(process=:hourly_assim, var=:A, scale=:Leaf, policy=Aggregate(MaxReducer()))) - -ModelSpec(DailyModel()) |> -TimeStepModel(ClockSpec(24.0, 1.0)) |> -InputBindings(; a=(process=:hourly_assim, var=:A, scale=:Leaf, policy=Integrate(vals -> maximum(vals) - minimum(vals)))) - -ModelSpec(DailyModel()) |> -TimeStepModel(ClockSpec(24.0, 1.0)) |> -InputBindings(; a=(process=:hourly_assim, var=:A, scale=:Leaf, policy=Integrate((vals, durations) -> sum(vals .* durations)))) - -ModelSpec(DailyModel()) |> -TimeStepModel(ClockSpec(24.0, 1.0)) |> -InputBindings(; a=(process=:hourly_assim, var=:A, scale=:Leaf, policy=Integrate(PlantMeteo.DurationSumReducer()))) -``` - -Built-in reducer types are: -`SumReducer()`, `MeanReducer()`, `MaxReducer()`, `MinReducer()`, `FirstReducer()`, `LastReducer()`. -The same reducer objects are also used by `MeteoBindings(...)`. -Custom reducers/callables can accept either `(values)` or `(values, durations_seconds)`. - -### Parameterized interpolation mode - -`Interpolate()` defaults to `mode=:linear, extrapolation=:linear`. - -```julia -ModelSpec(FastModel()) |> -TimeStepModel(1.0) |> -InputBindings(; x=(process=:slow_source, var=:x, policy=Interpolate())) - -ModelSpec(FastModel()) |> -TimeStepModel(1.0) |> -InputBindings(; x=(process=:slow_source, var=:x, policy=Interpolate(; mode=:hold, extrapolation=:hold))) -``` diff --git a/docs/src/API/public_symbols.md b/docs/src/API/public_symbols.md new file mode 100644 index 000000000..8e3d8c5fd --- /dev/null +++ b/docs/src/API/public_symbols.md @@ -0,0 +1,107 @@ +# Public Symbol Inventory + +This page records the supported default namespace. Compiler representations +and cache controls are intentionally listed separately under +[`PlantSimEngine.Advanced`](#advanced-namespace). + +## Scenario composition + +- CompositeModel structure: `CompositeModel`, `Object`, `ObjectId`, `CompositeModelTemplate`, + `ObjectInstance`, `Override`. +- Applications: `ModelSpec`, `AppliesTo`, `Inputs`, `Calls`, `TimeStep`, + `Environment`, `Updates`, `OutputRouting`. +- Application inspection: `application_name`, `applies_to`, `value_inputs`, + `model_calls`, `environment_config`, `output_routing`, `updates`. +- Dependency defaults: `Input`, `Call`, `PreviousTimeStep`. + +## Object selectors and queries + +- Multiplicity: `One`, `OptionalOne`, `Many`. +- Scope and topology: `SceneScope`, `Self`, `Subtree`, `SelfPlant`, `Ancestor`, + `Scope`, `Relation`. +- Labels: `Kind`, `Species`, `Scale`. +- Normalized addresses: `ObjectAddress`, `object_address`. +- Queries: `object_ids`, `model_objects`, `resolve_object_ids`, + `resolve_objects`. +- Object data: `geometry`, `position`, `bounds`. + +## Execution, lifecycle, and outputs + +- Execution: `run!`, `continue!`, `step!`, `Simulation`, `current_step`, + `runtime_model`. +- Output selection and collection: `OutputRequest`, `outputs`, + `collect_outputs`. +- Lifecycle: `register_object!`, `add_organ!`, `remove_object!`, + `reparent_object!`, `move_object!`, `update_geometry!`, + `mark_environment_binding_dirty!`, `objects_from_mtg`. +- Hard calls: `RunContext`, `CallTarget`, `CallTargets`, `call_targets`, + `run_call!`. + +## Structured explanations + +- Structure: `explain_objects`, `explain_instances`, `explain_scopes`. +- Compilation: `explain_applications`, `explain_bindings`, + `explain_calls`, `explain_model_bundles`, `explain_writers`, + `explain_schedule`, `explain_execution_plan`. +- Initialization, environment, and outputs: `explain_initialization`, + `explain_environment`, `explain_environment_bindings`, + `explain_output_retention`, `explain_outputs`. +- Supported carrier inspection: `input_carrier`, `input_value`, + `has_reference_carrier`. + +## Model-author contract + +- Model identity: `AbstractModel`, `@process`, `process`. +- State: `Status`, `init_variables`, `dep`. +- Model IO: `inputs`, `outputs`, `variables`, `meteo_inputs`, `meteo_inputs_`, + `meteo_outputs`, `meteo_outputs_`, `validate_meteo_inputs`. +- Timing and routing traits: `timespec`, `output_policy`, `timestep_hint`, + `meteo_hint`, `meteo_bindings`, `meteo_window`. + +The underscore declarations `inputs_`, `outputs_`, `meteo_inputs_`, and +`meteo_outputs_` are extension functions model authors implement with qualified +definitions such as `PlantSimEngine.inputs_(model) = ...`. `inputs_` and +`outputs_` remain intentionally unexported to avoid collisions with common +user functions. + +## Time and reducers + +- Scheduling: `ClockSpec`, `SchedulePolicy`, `HoldLast`, `Interpolate`, + `Integrate`, `Aggregate`. +- Reducers: `AbstractTimeReducer`, `MeanWeighted`, `MeanReducer`, `SumReducer`, + `MinReducer`, `MaxReducer`, `FirstReducer`, `LastReducer`, + `RadiationEnergy`. + +## Environment extension interface + +- Backend contract: `AbstractEnvironmentBackend`, `EnvironmentSupport`, + `GlobalConstant`, `environment_backend`, `environment_variables`, + `base_step_seconds`. +- Sampling and mutation: `sample`, `sample_environment`, `scatter!`, + `scatter_environment_outputs!`, `update_index!`. +- PlantMeteo conveniences: `Atmosphere`, `Constants`, `Weather`. + +## Evaluation + +- Fitting and metrics: `fit`, `RMSE`, `NRMSE`, `EF`, `dr`. + +## Advanced namespace + +`PlantSimEngine.Advanced` contains the qualified compiler and cache API: + +- registries and compiled representations: `ObjectRegistry`, `CompiledCompositeModel`, + `CompiledModelApplication`, `CompiledModelInputBinding`, + `CompiledModelCallBinding`, `CompiledEnvironmentBinding`, + `CompiledEnvironmentBindings`; +- carrier and adapter implementation types: `ObjectRefVector`, + `TimeStepTable`; +- compiler and cache operations: `compile_composite_model`, `refresh_bindings!`, + `refresh_environment_bindings!`, `compile_environment_bindings`, + `bind_environment`; +- cache diagnostics: `bindings_dirty`, `environment_bindings_dirty`, + `model_revision`, `environment_revision`, `compiled_bindings`, + `compiled_environment_bindings`. + +These names require explicit qualification or `using PlantSimEngine.Advanced`. +They are not part of the concise user namespace and may evolve with compiler +implementation requirements. diff --git a/docs/src/FAQ/translate_a_model.md b/docs/src/FAQ/translate_a_model.md deleted file mode 100644 index 89b2b6a4c..000000000 --- a/docs/src/FAQ/translate_a_model.md +++ /dev/null @@ -1,157 +0,0 @@ -# I want to use PlantSimEngine for my model - -```@setup mymodel -using PlantSimEngine -using CairoMakie -# Import the example models defined in the `Examples` sub-module: -using PlantSimEngine.Examples -using PlantMeteo, Dates - -function lai_toymodel(TT_cu; max_lai=8.0, dd_incslope=500, inc_slope=70, dd_decslope=1000, dec_slope=20) - LAI = max_lai * (1 / (1 + exp((dd_incslope - TT_cu) / inc_slope)) - 1 / (1 + exp((dd_decslope - TT_cu) / dec_slope))) - if LAI < 0 - LAI = 0 - end - return LAI -end - -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) -``` - -If you already have a model, you can easily use `PlantSimEngine` to couple it with other models with minor adjustments. - -## Toy LAI Model - -### Model description - -Let's take an example with a simple LAI model that we define below: - -```julia -""" -Simulate leaf area index (LAI, m² m⁻²) for a crop based on the amount of degree-days since sowing with a simple double-logistic function. - -# Arguments - -- `TT_cu`: degree-days since sowing -- `max_lai=8`: Maximum value for LAI -- `dd_incslope=500`: degree-days at which we get the maximal increase in LAI -- `inc_slope=5`: slope of the increasing part of the LAI curve -- `dd_decslope=1000`: degree-days at which we get the maximal decrease in LAI -- `dec_slope=2`: slope of the decreasing part of the LAI curve -""" -function lai_toymodel(TT_cu; max_lai=8.0, dd_incslope=500, inc_slope=70, dd_decslope=1000, dec_slope=20) - LAI = max_lai * (1 / (1 + exp((dd_incslope - TT_cu) / inc_slope)) - 1 / (1 + exp((dd_decslope - TT_cu) / dec_slope))) - if LAI < 0 - LAI = 0 - end - return LAI -end -``` - -This model takes the number of days since sowing as input and returns the simulated LAI. We can plot the simulated LAI for a year: - -```@example mymodel -using CairoMakie - -lines(1:1300, lai_toymodel.(1:1300), color=:green, axis=(ylabel="LAI (m² m⁻²)", xlabel="Days since sowing")) -``` - -### Changes for PlantSimEngine - -The model can be implemented using `PlantSimEngine` as follows: - -#### Define a process - -If the process of LAI dynamic is not implemented yet, we can define it like so: - -```julia -@process LAI_Dynamic -``` - -#### Define the model - -We have to define a structure for our model that will contain the parameters of the model: - -```julia -struct ToyLAIModel <: AbstractLai_DynamicModel - max_lai::Float64 - dd_incslope::Int - inc_slope::Float64 - dd_decslope::Int - dec_slope::Float64 -end -``` - -We can also define default values for the parameters by defining a method with keyword arguments: - -```julia -ToyLAIModel(; max_lai=8.0, dd_incslope=500, inc_slope=70, dd_decslope=1000, dec_slope=20) = ToyLAIModel(max_lai, dd_incslope, inc_slope, dd_decslope, dec_slope) -``` - -This way users can create a model with default parameters just by calling `ToyLAIModel()`, or they can specify only the parameters they want to change, *e.g.* `ToyLAIModel(inc_slope=80.0)` - -#### Define inputs / outputs - -Then we can define the inputs and outputs of the model, and the default value at initialization: - -```julia -PlantSimEngine.inputs_(::ToyLAIModel) = (TT_cu=-Inf,) -PlantSimEngine.outputs_(::ToyLAIModel) = (LAI=-Inf,) -``` - -!!! note - Note that we use `-Inf` for the default value, it is the recommended value for `Float64` (-999 for `Int`), as it is a valid value for this type, and is easy to catch in the outputs if not properly set because it propagates nicely. You can also use `NaN` instead. - -#### Define the model function - -Finally, we can define the model function that will be called at each time step: - -```julia -function PlantSimEngine.run!(::ToyLAIModel, models, status, meteo, constants=nothing, extra=nothing) - status.LAI = models.LAI_Dynamic.max_lai * (1 / (1 + exp((models.LAI_Dynamic.dd_incslope - status.TT_cu) / model.LAI_Dynamic.inc_slope)) - 1 / (1 + exp((models.LAI_Dynamic.dd_decslope - status.TT_cu) / models.LAI_Dynamic.dec_slope))) - - if status.LAI < 0 - status.LAI = 0 - end -end -``` - -!!! note - Note that we don't return the value of the LAI in the definition of the function. This is because we rather update its value in the status directly. The status is a structure that efficiently stores the state of the model at each time step, and it contains all variables either declared as inputs or outputs of the model. This way, we can access the value of the LAI at any time step by calling `status.LAI`. - -!!! note - The function is defined for **one time step** only, and is called at each time step automatically by PlantSimEngine. This means that we don't have to loop over the time steps in the function. - -#### [Running a simulation](@id defining_the_meteo) - -Now that we have everything set up, we can run a simulation. The first step here is to define the weather: - -```julia -# Import the packages we need: -using PlantMeteo, Dates - -# Define the period of the simulation: -period = [Dates.Date("2021-01-01"), Dates.Date("2021-12-31")] - -# Get the weather data for CIRAD's site in Montpellier, France: -meteo = get_weather(43.649777, 3.869889, period, sink = DataFrame) - -# Compute the degree-days with a base temperature of 10°C: -meteo.TT = max.(meteo.T .- 10.0, 0.0) - -# Aggregate the weather data to daily values: -meteo_day = to_daily(meteo, :TT => (x -> sum(x) / 24) => :TT) -``` - -Then we can define our list of models, passing the values for `TT_cu` in the status at initialization: - -```@example mymodel -m = ModelMapping( - ToyLAIModel(), - status = (TT_cu = cumsum(meteo_day.TT),), -) - -outputs_sim = run!(m) - -lines(outputs_sim[:TT_cu], outputs_sim[:LAI], color=:green, axis=(ylabel="LAI (m² m⁻²)", xlabel="Days since sowing")) -``` diff --git a/docs/src/agent_skill.md b/docs/src/agent_skill.md index e432ba383..e37a6e22c 100644 --- a/docs/src/agent_skill.md +++ b/docs/src/agent_skill.md @@ -8,12 +8,33 @@ The skill file is stored in the repository at: skills/plantsimengine/SKILL.md ``` -Users can download the `skills/plantsimengine` folder and tell their agent to use the `plantsimengine` skill when working with PlantSimEngine.jl. The skill gives agents the package-specific conventions they need for: +Users can download the `skills/plantsimengine` folder and tell their agent to +use the `plantsimengine` skill when working with PlantSimEngine.jl. The skill +gives agents the package-specific conventions they need for: -- composing existing models with `ModelMapping`; -- declaring spatial multiscale mappings with scale symbols and `MultiScaleModel`; -- configuring multirate simulations with `ModelSpec`, `TimeStepModel`, `InputBindings`, and temporal policies; -- implementing or wrapping models with `@process`, `inputs_`, `outputs_`, `run!`, hard dependencies, and model traits. +- building object graphs with `CompositeModel`, `Object`, `CompositeModelTemplate`, and + `ObjectInstance`; +- applying models with `ModelSpec` and `AppliesTo`; +- coupling values and manual model calls with `Inputs` and `Calls`; +- configuring multirate simulations with `TimeStep`, `Dates.Period` values, + and temporal policies; +- binding global or spatial microclimate through `Environment`; +- reasoning about model-clock weather aggregation, `meteo_hint` reducers, and + scenario source overrides; +- inspecting compiled scenarios with structured explanation helpers; +- reporting supplied, generated, bound, and unresolved variables with + `explain_initialization`; +- accessing the live model from lifecycle-capable kernels with + `runtime_model(extra)`; +- inspecting homogeneous runtime batches with `explain_execution_plan`; +- collecting raw or requested model outputs with `outputs`, + `OutputRequest`, `collect_outputs`, and `explain_output_retention`; +- implementing or wrapping models with `@process`, `inputs_`, `outputs_`, + `run!`, hard dependencies, and model traits. + +The superseded `ModelMapping` and `MultiScaleModel` runtimes have been removed. +Use [Migrating To The CompositeModel/Object API](migration_composite_model.md) when +translating historical code. The canonical source is [`skills/plantsimengine/SKILL.md`](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/skills/plantsimengine/SKILL.md). diff --git a/docs/src/composite_model/quickstart.md b/docs/src/composite_model/quickstart.md new file mode 100644 index 000000000..b0dc0d649 --- /dev/null +++ b/docs/src/composite_model/quickstart.md @@ -0,0 +1,268 @@ +# CompositeModel/Object Quickstart + +This page is the shortest path to a native composite-model/object simulation. + +Use this API for new multiscale, multi-plant, soil, microclimate, and +model-scale simulations: + +```julia +CompositeModel +Object +ModelSpec +AppliesTo +Inputs +Calls +Updates +TimeStep +Environment +``` + +Scenarios are defined with `CompositeModel` and model applications. A model is a +reusable process implementation; an application is one configured use of that +model, including its name, target objects, inputs, cadence, and environment. +One application may run on many objects, and the same model may appear in +several applications with different parameters or targets. + +```@setup model_object_quickstart +using PlantSimEngine, PlantMeteo, Dates, DataFrames +using PlantSimEngine.Examples +``` + +## One Object, Several Models + +For models that all run on one object, the concise constructor lowers directly +to the ordinary CompositeModel/Object representation: + +```julia +model = CompositeModel(ModelA(), ModelB(); status=(initial_value=1.0,)) +``` + +Use the explicit form later in this guide when applications need names, +selectors, or other scenario policies. + +The first model has one object, `:scene`, and three model applications: + +- `ToyDegreeDaysCumulModel` computes daily thermal time; +- `ToyLAIModel` consumes cumulative thermal time and computes LAI; +- `Beer` consumes LAI and meteorology to compute absorbed PAR. + +The model implementations are ordinary PlantSimEngine kernels. The model +application layer decides where they run. With no explicit `TimeStep`, these +applications use the environment cadence. + +```@example model_object_quickstart +meteo_day = read_weather( + joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"); + duration=Dates.Day, +) + +model = CompositeModel( + ToyDegreeDaysCumulModel(), + ToyLAIModel(), + Beer(0.6); + environment=meteo_day, +) + +sim = run!(model; steps=30, outputs=:all) +out = collect_outputs(sim; sink=DataFrame) +first(out, 6) +``` + +The model object now holds the latest status values: + +```@example model_object_quickstart +scene_status = only(model_objects(model; scale=:Scene)).status +(TT_cu=scene_status.TT_cu, LAI=scene_status.LAI, aPPFD=scene_status.aPPFD) +``` + +## Inspect The Compiled Bindings + +Before running, `explain_initialization(model)` classifies each variable as +`:supplied`, `:generated`, `:producer_bound`, `:environment_bound`, or +`:unresolved`. The report remains available when ordinary required values are +missing, so it can be used to finish configuring a model. + +The compiler infers unambiguous same-object dependencies from declared model +inputs and outputs: + +- `:LAI_Dynamic` reads `TT_cu` from `:Degreedays`; +- `:light_interception` reads `LAI` from `:LAI_Dynamic`. + +```@example model_object_quickstart +select( + DataFrame(explain_bindings(model)), + :application_id, + :input, + :source_application_ids, + :carrier_kind, + :copy_semantics, +) +``` + +`carrier_kind = :ref` and `copy_semantics = :live_references` mean the +consumer sees a shared reference rather than a copied value. + +For runtime performance diagnostics, inspect the execution plan: + +```@example model_object_quickstart +select( + DataFrame(explain_execution_plan(model)), + :application_id, + :object_ids, + :batch_size, + :inner_loop_dispatch, +) +``` + +## Request Outputs + +By default, model runs retain no user output streams. Pass `outputs=:all` to +retain every publisher, or pass `OutputRequest` values to retain and +materialize selected streams plus those required by temporal inputs. + +```@example model_object_quickstart +request = OutputRequest( + Many(scale=:Scene), + :LAI; + name=:lai_every_two_days, + application=:LAI_Dynamic, + policy=HoldLast(), + clock=Day(2), +) + +requested_sim = run!( + model; + steps=30, + outputs=request, +) + +collect_outputs(requested_sim, :lai_every_two_days; sink=nothing)[1:4] +``` + +The retention explanation reports why a stream was kept: + +```@example model_object_quickstart +explain_output_retention(requested_sim) +``` + +## Many Objects As Inputs + +Use `Inputs(...)` when a model needs values from selected objects. This +model-scale LAI model reads live references to the surface of every plant: + +```@example model_object_quickstart +plant_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :plant_1; + scale=:Plant, + kind=:plant, + parent=:scene, + status=Status(surface=12.0), + ), + Object( + :plant_2; + scale=:Plant, + kind=:plant, + parent=:scene, + status=Status(surface=8.0), + ); + applications=( + ModelSpec(ToyLAIfromLeafAreaModel(100.0); name=:scene_lai) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :plant_surfaces => Many( + scale=:Plant, + within=SceneScope(), + var=:surface, + ), + ), + ), +) + +run!(plant_scene) +plant_model_status = only(model_objects(plant_scene; scale=:Scene)).status +(total_surface=plant_model_status.total_surface, LAI=plant_model_status.LAI) +``` + +The compiled binding shows a `RefVector` carrier: + +```@example model_object_quickstart +select( + DataFrame(explain_bindings(plant_scene)), + :application_id, + :input, + :source_ids, + :carrier_kind, + :copy_semantics, +) +``` + +If the consumer model runs on each plant, use `within=Subtree()` to read only +objects inside the current plant. Use `within=SceneScope()` when a model model +must aggregate all matching objects. + +## Manual Calls + +Use `Calls(...)` when a parent model must directly run selected child models. +This is the mechanism for iterative solvers such as model energy balance: + +```julia +ModelSpec(SceneEnergyBalance(); name=:scene_energy) |> + AppliesTo(One(scale=:Scene)) |> + Calls( + :leaf_energy => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:energy_balance, + ), + :soil => One( + kind=:soil, + scale=:Soil, + within=SceneScope(), + application=:soil_water, + ), + ) |> + TimeStep(Hour(1)) +``` + +For a one-shot call, execute all targets directly: + +```julia +targets = run_call!(extra, :leaf_energy; meteo=meteo, publish=true) +``` + +`targets` is always vector-like regardless of whether the declaration uses +`One`, `OptionalOne`, or `Many`. For iterative control, retrieve the same +compiled targets without executing them: + +```julia +function PlantSimEngine.run!(model::SceneEnergyBalance, models, status, meteo, + constants, extra) + for target in call_targets(extra, :leaf_energy) + run_call!(target; meteo=trial_meteo(model, status)) + end + + for target in call_targets(extra, :leaf_energy) + run_call!(target; meteo=accepted_meteo(model, status), publish=true) + end + + return nothing +end +``` + +`run_call!` defaults to `publish=false`, so trial calls mutate target statuses +without publishing temporal streams or mutable environment outputs. The +accepted state should use `publish=true`. + +## Next Steps + +- [Migrating To The CompositeModel/Object API](../migration_composite_model.md) translates + scenarios written with removed APIs. +- [Public API](../API/API_public.md) lists constructors, selectors, lifecycle + helpers, environment helpers, and explanation helpers. +- [Model traits](../model_traits.md) documents `inputs_`, `outputs_`, `dep`, + `timespec`, `output_policy`, `meteo_inputs_`, and `meteo_outputs_`. +- [MAESPA-style model example handoff](../dev/maespa_model_handoff.md) + records the current multi-plant model energy-balance acceptance example. diff --git a/docs/src/dev/code_cleanup_audit.md b/docs/src/dev/code_cleanup_audit.md new file mode 100644 index 000000000..d803b9a5e --- /dev/null +++ b/docs/src/dev/code_cleanup_audit.md @@ -0,0 +1,97 @@ +# Code Cleanup Audit + +## Status + +The compatibility cleanup is implemented on the `multi-plants` branch. + +The package now has one scenario compiler and runtime: the composite-model/object API. +The following superseded implementations were removed rather than deprecated: + +- `ModelList` and `SingleScaleModelSet`; +- `ModelMapping` and `MultiScaleModel`; +- `DependencyGraph`, `HardDependencyNode`, and `SoftDependencyNode`; +- `GraphSimulation` and the MTG mapping runner; +- mapping-specific multirate input resolution and output export; +- the unreleased domain prototype that preceded the composite-model/object API, + including `Domain`, `SimulationMapping`, `Route`, `AllDomains`, + `HardDomains`, and its separate scheduler, runtime, environment bridge, and + output publisher; +- mapping-only initialization, dataframe, dimension, and topology helpers; +- unused parallel-executor traits after removal of the executor runtime; +- dead `UninitializedVar`, `RefVariable`, `TreeAlike`, and `StatusView` types; +- the unreleased `CompositeModelTemplate(...; mapping=...)` alias and legacy + selector-to-mapping conversion helpers; +- compatibility tests, tutorials, and executable examples. + +Migration details are retained in +[`migration_composite_model.md`](../migration_composite_model.md) and +[`release_notes_handoff.md`](release_notes_handoff.md). + +## Current Ownership + +| Concern | Owner | +| --- | --- | +| Object registry, selectors, compilation, execution, lifecycle | `src/composite_model_api.jl` | +| Model application configuration | `src/ModelSpec.jl` | +| Status and reference vectors | `src/component_models/Status.jl`, `src/component_models/RefVector.jl` | +| Dates-based clocks and policies | `src/time/multirate.jl`, `src/time/runtime/clocks.jl` | +| Weather sampling | `src/time/runtime/meteo_sampling.jl` | +| Environment backends | `src/time/runtime/environment_backends.jl` | +| Output request definition | `src/time/runtime/output_export.jl` | + +## Remaining Review Rules + +Future cleanup should reject: + +- a second scenario/runtime abstraction parallel to `CompositeModel`; +- compatibility wrappers for unreleased APIs; +- package-specific behavior in PlantSimEngine; +- model kernels that know their scenario object, timestep, or coupling unless + the scientific algorithm requires a hard call; +- dynamic per-object dispatch or copying in hot loops when compiled typed + batches and reference carriers are available; +- undocumented public names or agent instructions that describe removed APIs. + +## Verification + +Current cleanup evidence: + +- the `src` tree contains only the composite-model/object runtime and supporting status, + time, environment, fitting, trait, and example files; +- empty directories left by removed subsystems were deleted; +- `git diff --check` passes; +- PlantSimEngine precompiles and loads from a clean Kaimon session; +- `test/test-unified-model-object-api.jl` passes 576 tests; +- the complete package environment passes 885 tests, including Aqua and + doctests; +- `test/test-fitting.jl` passes; +- the documentation build passes, including executable examples and + cross-reference checks; +- repository search finds no superseded scenario-runtime definitions or + references in source, tests, examples, README, public docs, or the packaged + agent skill; +- remaining `ModelMapping` and `MultiScaleModel` references outside + development notes are migration text that explicitly points historical code + to the composite-model/object API. +- repository search finds no `Domain`, `SimulationMapping`, `Route`, + `AllDomains`, or `HardDomains` implementations, exports, tests, examples, or + public documentation. The only remaining references are development/release + notes that record their removal from this unreleased branch; +- ignored `.DS_Store` files were removed from the working tree outside `.git`. +- `ModelSpec` pipe-helper boilerplate was consolidated behind shared internal + helpers while keeping the public `AppliesTo`, `Inputs`, `Calls`, `TimeStep`, + `Environment`, `Updates`, and `OutputRouting` grammar unchanged. +- the duplicate `Advanced.TimeStepTable` export was removed from the PlantMeteo + re-export block; `Advanced.TimeStepTable` remains exported once from the core status + export list. +- stale `PlantSimEngine.Examples` exports for the deleted + `ToyInternodeEmergence` example were removed. + +Downstream verification: + +- PlantBiophysics passes 117/117 tests against this working tree. +- XPalm's uncommitted CompositeModel/Object migration executes 74/75 assertions. The + remaining assertion retains the removed runtime's first-step LAI value, + while the explicit current dependency order produces zero from the initial + zero-biomass leaf before plant/model aggregation. PlantSimEngine intentionally + contains no package-specific compatibility workaround for that stale fixture. diff --git a/docs/src/dev/composite_model_completion_audit.md b/docs/src/dev/composite_model_completion_audit.md new file mode 100644 index 000000000..b68a88593 --- /dev/null +++ b/docs/src/dev/composite_model_completion_audit.md @@ -0,0 +1,100 @@ +# Unified CompositeModel/Object Completion Audit + +This audit records the evidence used to assess the breaking composite-model/object +redesign against `composite_model_design.md` and +`composite_model_implementation_plan.md`. + +Audit date: June 12, 2026. + +## Result + +The unified composite-model/object redesign is implemented as the primary public +configuration API. + +The superseded mapping implementation and the unreleased intermediate +prototype were removed after the composite-model/object runtime replaced them. + +## Public Contract + +The exported scenario vocabulary centers on: + +```julia +CompositeModel +Object +ModelSpec +AppliesTo +Inputs +Calls +Updates +TimeStep +Environment +``` + +Legacy scenario constructors such as `ModelMapping` and `MultiScaleModel` were +removed. + +Evidence: + +- `src/PlantSimEngine.jl` +- `docs/src/API/API_public.md` +- `docs/src/migration_composite_model.md` +- `README.md` + +## Requirement Evidence + +| Requirement | Evidence | +| --- | --- | +| One model object registry without prescribing plant topology | `CompositeModel`, `Object`, selectors, relations, scopes, and `objects_from_mtg` in `src/composite_model_api.jl`; selector and MTG adapter tests in `test/test-unified-model-object-api.jl` | +| Reusable species models and repeated plant instances | `CompositeModelTemplate`, `ObjectInstance`, `Override`, shared model ownership, and homogeneous/heterogeneous batches; four-instance and override tests | +| Soft/value dependencies | Compiled `Inputs(...)` bindings, same-object inference, scalar references, `RefVector`, `Advanced.ObjectRefVector`, temporal carriers, renaming, and optional inputs | +| Manual hard dependencies | Compiled `Calls(...)`, vector-like `CallTargets`, `run_call!(context, name)`, and fine-grained `run_call!(target)`; trial calls default to `publish=false` and accepted calls publish explicitly | +| Model-author dependency defaults | `Input(...)` and `Call(...)` entries from `dep(model)`, with `ModelSpec` overrides and binding provenance in explanations | +| Multirate execution | `TimeStep(Dates.Period)`, model `timespec`, input policies, explicit windows, `PreviousTimeStep`, and stable compiled scheduling | +| Generic value types | Reference and temporal tests using `ModelObjectDualLike{BigFloat}` and `BigFloat` interpolation/integration without `Float64` conversion | +| Reference semantics and low-copy execution | Shared scalar references, typed many-object carriers, preinstalled status bindings, zero-allocation materialization tests, and a zero-allocation warmed 128-object execution batch | +| Duplicate writers | Canonical writer validation and `Updates(:variable; after=...)`, including pruning-after-allocation tests | +| Growth, pruning, reparenting, and movement | Central lifecycle APIs, structural/environment cache invalidation, dynamic target/carrier rebuilding, removed-object output history, and object-scoped geometry refresh tests | +| Automatic meteorology and microclimate | `meteo_inputs_`, `meteo_outputs_`, global and spatial backends, ancestor geometry fallback, source remapping, model hints, tabular aggregation, cached bindings, and mutable backend scattering | +| Structured agent explanations | Object, instance, scope, application, binding, call, model-bundle, environment, schedule, writer, execution-plan, output, and retention explanations returning structured rows | +| Initialization workflow | `explain_initialization` classifies supplied, generated, producer-bound, environment-bound, and unresolved variables without failing solely on unresolved values | +| Runtime model access | `runtime_model` is the public accessor used by lifecycle-capable kernels and accepts `CompositeModel`, `RunContext`, and `Simulation` | +| One-object ergonomics | `CompositeModel(model, models...; status=...)` lowers to the same object/application compiler and runtime as explicit construction | +| Output ownership and retention | Application-qualified streams, `OutputRouting`, `OutputRequest(application=...)`, dynamic-object exports, and bounded policy-specific dependency histories | +| MAESPA acceptance case | `build_maespa_scene` and `run_maespa_example` use `CompositeModelTemplate`, `ObjectInstance`, `AppliesTo`, `Inputs`, `Calls`, and `TimeStep`; verified by `test/test-maespa-model-example.jl` | +| Documentation and migration | CompositeModel/object-first README, home page, quickstart, execution guide, public API, migration guide, and explicitly labeled legacy reference sections | + +## Verification + +The following gates passed from a clean, controllable Kaimon Julia session: + +```text +test/test-unified-model-object-api.jl 576 passed +test/runtests.jl 885 passed +docs/make.jl passed +PlantBiophysics/test/runtests.jl 117 passed +git diff --check passed +``` + +The complete package suite includes Aqua, all focused restoration files, the +broad unified runtime regression, examples, fitting, and doctests. The +documentation build completed its executable examples, doctests, +cross-references, document checks, and HTML rendering successfully. + +The current uncommitted XPalm CompositeModel/Object migration loads this PlantSimEngine +worktree and executes 74 of 75 downstream assertions. Its remaining assertion +expects the removed runtime's first-step LAI (`0.000272`); the current compiled +dependency order correctly runs leaf area before plant and model aggregation, +so the initial zero-biomass leaf produces `0.0`. This is a downstream fixture +expectation in a dirty migration worktree, not a package-specific behavior to +restore in PlantSimEngine. No XPalm workaround was added here. + +## Compatibility Boundary + +Historical mapping source and tests were removed. Migration guidance remains +in the documentation for downstream packages moving to the composite-model/object API. +The branch-only intermediate prototype was also removed because it was never +released and has no compatibility boundary. + +Requested output histories are still materialized after a run. Dependency-only +temporal histories are bounded, but a fully online output sink would be an +additional optimization rather than a missing requirement of this redesign. diff --git a/docs/src/dev/composite_model_design.md b/docs/src/dev/composite_model_design.md new file mode 100644 index 000000000..1516f2f8c --- /dev/null +++ b/docs/src/dev/composite_model_design.md @@ -0,0 +1,729 @@ +# Unified CompositeModel/Object Design + +This page records the target breaking design for one composite-model/object +configuration and runtime API. + +The central idea is: + +> Structural groupings and scales are selections over objects in one model. + +The engine should expose one way to say "this model input comes from these +objects" and one way to say "this model must manually call these models". The +compiler can then choose whether the runtime carrier is a `Ref`, `RefVector`, +temporal stream, materialized value, or callable model handle. + +The public API should be simple enough to remember as: + +```julia +CompositeModel +Object +ModelSpec + +AppliesTo(...) +Inputs(...) +Calls(...) +Updates(...) +TimeStep(...) +Environment(...) +``` + +Everything else should either be a selector, a trait declared by the model +author, or an internal compiled carrier. + +## Core Concepts + +### CompositeModel + +A `CompositeModel` is the whole simulation universe. It contains: + +- simulated objects; +- model applications; +- environment providers; +- time/runtime state; +- caches for object selections and environment bindings. + +Plants, soil, atmosphere, microclimate grids, organs, sensors, and artificial +objects all live in the same model-level object graph. + +### Object + +An object is any simulated entity with identity. It may have: + +- a unique object id; +- one or more labels, such as `scale=:Leaf`, `kind=:plant`, + `species=:oil_palm`; +- parent/child links; +- geometry or position; +- status variables; +- model applications. + +The engine must not prescribe a plant architecture. A plant can be described as +`Plant -> Internode -> Leaf`, `Plant -> Axis -> Segment -> Leaf`, +`Plant -> Metamer -> Organ`, or another topology. The engine only needs object +identity, labels, and relations. + +Existing `MultiScaleTreeGraph.Node` topologies enter the same registry through +`objects_from_mtg(root; ...)` or `CompositeModel(root; ...)`. The adapter traverses once +and accepts accessors for ids, labels, status, and geometry; the timestep +runtime does not query the MTG topology. + +### Scale + +A scale is a label on objects, not a separate runtime layer. Examples: + +```julia +:Scene +:Plant +:Axis +:Internode +:Leaf +:Soil +:SoilLayer +:Voxel +``` + +### Scope + +A scope is a named or inferred subset of objects. Examples: + +```julia +SceneScope() +Self() +SelfPlant() +Ancestor(scale=:Plant) +Scope(:oil_palm) +Kind(:plant) +Species(:oil_palm) +``` + +`Self()` means only the current model application object. `Subtree()` means +that object and its descendants. Neither spelling changes meaning with scale. + +`SelfPlant()` is the nearest containing plant scope. The more generic form is +`Ancestor(scale=:Plant)`. Use these when a model running below the plant scale +must access siblings or state inside the containing plant. + +Reusable plant models should use scope-relative queries. If an allocation +model is applied to each `:Plant`, `Many(scale=:Leaf, within=Subtree())` means +"the leaves inside this plant", not all leaves in the model. The same query +applied to an axis-scale model means "the leaves inside this axis". + +CompositeModel-level models widen the scope explicitly with `within=SceneScope()`. + +Topology-relative selections use `Relation(...)`: + +```julia +One(Relation(:parent)) +Many(Relation(:children)) +Many(Relation(:ancestors), Scale(:Plant)) +Many(Relation(:descendants), Scale(:Leaf)) +Many(Relation(:siblings)) +``` + +Supported relations are `:self`, `:parent`, `:children`, `:ancestors`, +`:descendants`, and `:siblings`. They resolve relative to the current model +application object. An explicit `within=...` scope intersects the relation +result; inferred default scopes do not hide parents or siblings. Relation +results are normalized to stable object-id order before bindings are compiled. + +### Object Template And Instance + +An object template is a reusable model/parameter bundle, for example one oil +palm species model. An object instance is one concrete object in the model. + +The same template can be mounted several times: + +```julia +oil_palm = CompositeModelTemplate( + kind=:plant, + species=:oil_palm, + applications=oil_palm_applications, + parameters=oil_palm_parameters, +) + +model = CompositeModel( + ObjectInstance(:palm_1, oil_palm; root=node1), + ObjectInstance(:palm_2, oil_palm; root=node2), + ObjectInstance(:palm_3, oil_palm; root=node3), + ObjectInstance(:palm_4, oil_palm; root=node4), +) +``` + +Models and parameters can be overridden at instance or object level: + +```julia +ObjectInstance(:palm_2, oil_palm; overrides=( + stomatal_conductance = Tuzet(; g1=3.2), +)) + +Override( + object=:leaf_12, + process=:photosynthesis, + model=Fvcb(; VcMaxRef=90.0), +) +``` + +Ownership is reference-based and explicit: + +- a template retains the supplied model and parameter objects without copying; +- unchanged instances share those exact objects; +- an instance override replaces one complete model application with another + user-owned model object; +- an object override replaces that application only for the selected object; +- PlantSimEngine does not mutate model fields or implicitly merge parameter + dictionaries. + +Overrides must preserve the model contract: process identity and declared +status/environment variable names cannot change. Parameter-only overrides of +the same concrete model type retain concrete runtime dispatch. Heterogeneous +alternative implementations are supported but may require dynamic dispatch for +the exceptional application. + +### Model Kernel And Model Application + +A model kernel is the reusable model implementation written by a modeler. It +defines a process, parameters, `inputs_`, `outputs_`, optional `dep` defaults, +optional environment traits, and `run!`. + +A model application is the scenario-specific use of that kernel on selected +objects, at a selected rate, with selected value inputs, model calls, update +rules, output routing, and environment binding behavior. + +The model kernel should not need to know: + +- the species it will be used with; +- the model it will be embedded in; +- the timestep chosen by the user; +- whether its inputs come from local state, another scale, another object, a + temporal stream, units, automatic differentiation values, or uncertainty + wrappers. + +The scenario owns those decisions through `ModelSpec`. + +Target shape: + +```julia +ModelSpec(LeafEnergyBalance(); name=:leaf_energy) |> + AppliesTo(Many(kind=:plant, scale=:Leaf)) |> + Inputs(...) |> + Calls(...) |> + TimeStep(Hour(1)) +``` + +Application names are optional but important when the same process appears more +than once in the same object set. Other declarations can target either +`process=:photosynthesis` or `name=:sunlit_photosynthesis` when a process alone +is ambiguous. + +## Unified Model Configuration + +`ModelSpec` is the single scenario wrapper. Released mapping-era configuration +is replaced by explicit value inputs and callable model calls. + +### Applies To + +Use `AppliesTo(...)` to declare the object set where a model application runs. +This should be first-class, not inferred from a container or mapping key. + +```julia +ModelSpec(LeafState()) |> + AppliesTo(Many(kind=:plant, scale=:Leaf)) + +ModelSpec(AllocationModel()) |> + AppliesTo(Many(kind=:plant, scale=:Plant)) + +ModelSpec(SceneEB()) |> + AppliesTo(One(scale=:Scene)) +``` + +The same model kernel can be applied several times with different selectors, +parameters, timesteps, or input bindings. The compiler should normalize each +application to a stable application id. + +### Dependency Defaults From Traits + +Model authors should still declare `inputs_`, `outputs_`, and `dep`. In the +final design, `dep(model)` is the model-level place for default dependency +intent. Historical `ModelMapping` declarations are migration inputs to the +CompositeModel/Object runtime, not a second supported path. + +The rule is: + +- `inputs_(model)` declares the variables the model needs; +- `outputs_(model)` declares the variables the model computes; +- `dep(model)` declares default value sources or manual model calls when the + model author knows a sensible coupling pattern; +- `ModelSpec(...) |> Inputs(...)` and `ModelSpec(...) |> Calls(...)` override + or specialize those defaults for a specific simulation. + +For example, a plant allocation model can provide a plant-local default: + +```julia +dep(::PlantAllocationModel) = ( + leaf_carbon = Input(Many(scale=:Leaf, within=Subtree(), var=:leaf_carbon)), +) +``` + +An energy-balance model can declare that it usually calls a stomatal +conductance model manually: + +```julia +dep(::LeafEnergyBalanceModel) = ( + stomatal_conductance = Call(process=:stomatal_conductance), +) +``` + +These trait defaults are not absolute wiring. They are model-author defaults +that make common cases work without repeating configuration, while scenario +authors keep final authority through `ModelSpec`. + +Compiler order: + +1. read `inputs_`, `outputs_`, and `dep`; +2. infer simple same-object value dependencies when unambiguous; +3. apply `dep(model)` defaults for value inputs and model calls; +4. apply `ModelSpec` overrides last. + +This order is part of the public contract. It keeps modeler defaults useful +without making them final wiring. Missing or ambiguous inputs after this pass +are errors, not incidental fallback behavior. + +### Value Inputs + +Use `Inputs(...)` when a model needs values before its `run!` method executes: + +```julia +ModelSpec(LAIModel(ground_area)) |> + Inputs(:leaf_areas => Many(scale=:Leaf, within=SceneScope(), var=:leaf_area)) +``` + +Reusable plant allocation: + +```julia +ModelSpec(AllocationModel()) |> + AppliesTo(Many(scale=:Plant)) |> + Inputs(:leaf_carbon => Many(scale=:Leaf, within=Subtree(), var=:leaf_carbon)) +``` + +The same declaration must compile to: + +- direct `Ref`/`RefVector` wiring when producer and consumer live in the same + object graph and rate; +- temporal stream reads when producer and consumer run at different rates; +- materialization when target status must be assigned before a + model runs; +- source-status lookup for graph-backed object selections. + +The important user rule is: + +- `Inputs(...)` means "give this model values"; the runtime schedules or + samples producers; +- the receiving model never manually calls the producer because of an + `Inputs(...)` declaration. + +### Carrier And Copy Semantics + +The compiler chooses the carrier, but the semantics must be documented and +explainable: + +| Situation | Preferred carrier | Copy behavior | +| --- | --- | --- | +| same-rate scalar input | shared `Ref` or local alias | no copy when possible | +| same-rate `Many(...)` input | `RefVector` or equivalent typed reference collection | no copy for live values | +| cross-rate input | temporal stream sample | value materialized for the consumer timestep | +| `Integrate` or `Aggregate` input | temporal window reduction | reduced value materialized | +| materialized target status input | compiler-generated assignment | assigned before consumer run | +| environment input | cached `EnvironmentBinding` sample | backend-defined value sample | + +This table is a required part of the design because performance, units, +automatic differentiation, and error propagation depend on preserving user +value types and avoiding hidden copies. + +PlantSimEngine should not force `Float64` internally. Status values, +parameters, meteo values, and outputs must be allowed to use units, dual +numbers, uncertainty wrappers, tracked arrays, or other numeric-like types. +Compiled carriers should be parametric and type stable whenever the object set +and value type are known at initialization. + +### Multirate Inputs + +Multirate must be supported by the same `Inputs(...)` declaration, not a +separate mapping language. The public time language should remain `Dates` +periods. + +Example: + +```julia +ModelSpec(PlantAllocation()) |> + AppliesTo(Many(kind=:plant, scale=:Plant)) |> + Inputs(:leaf_assimilation => Many( + scale=:Leaf, + within=Subtree(), + var=:assimilation, + policy=Integrate(), + window=Day(1), + )) |> + TimeStep(Day(1)) +``` + +Policy precedence should stay explicit: + +1. input-level policy in `Inputs(...)`; +2. producer `output_policy(model)`; +3. default `HoldLast()`. + +Cross-rate links must go through temporal state even when they point to objects +that could otherwise be reference-wired. + +Same-timestep feedback cycles are broken explicitly on the receiving input: + +```julia +ModelSpec(CarbonState()) |> + Inputs( + PreviousTimeStep(:carbon_biomass) => One( + scale=:Plant, + process=:carbon_allocation, + var=:carbon_biomass, + ), + ) +``` + +`PreviousTimeStep(:x)` removes the producer-to-consumer edge from the current +timestep graph and reads the latest source sample at or before the previous +model timestep. Before a source sample exists, the initialized consumer status +value for `x` is used. This makes initialization part of the scenario contract +instead of silently inventing a zero value. + +### Model Calls + +Use `Calls(...)` when a model must manually run selected models, typically +inside an iterative solver. This is the required public API name and must be +implemented as part of the unified composite-model/object redesign, not left as a later +rename. + +```julia +ModelSpec(SceneEB()) |> + Calls(:leaf_energy => Many( + kind=:plant, + scale=:Leaf, + process=:energy_balance, + )) |> + Calls(:soil => One(kind=:soil, application=:soil_water)) +``` + +Inside `run!`, the model model receives call handles and calls +`run_call!(call)` during trial iterations, then +`run_call!(call; publish=true)` for the accepted final solution. The default is +deliberately `publish=false`: trial calls mutate target status for convergence +checks but do not append temporal samples or write environment outputs. + +The important user rule is: + +- `Calls(...)` means "give this model callable model handles"; +- the parent model owns the call stack and can iterate, reject, or accept trial + calls; +- call outputs are published only according to the call publication contract. + +`explain_calls(compiled)` exposes this as +`publication_policy=:explicit_accept`, with `default_publish=false` and +`accepted_publish=true`. + +Binding and call explanations also report where each dependency declaration +came from: + +- `origin=:inferred_same_object` for compiler-inferred value dependencies; +- `origin=:model_default` for `Input(...)` or `Call(...)` declarations coming + from `dep(model)`; +- `origin=:model_spec` for scenario-level `Inputs(...)` or `Calls(...)`, + including declarations that override a model default. + +### Multiplicity + +Selection multiplicity is explicit: + +```julia +One(...) +Many(...) +OptionalOne(...) +``` + +`OptionalOne(...)` resolves to zero or one dependency. With zero matches, an +input keeps its `inputs_` default and a call returns an empty +`call_targets(...)` collection. Explanations retain these unresolved +optional bindings instead of hiding them. + +The compiler validates that `One(...)` resolves to exactly one producer per +consumer scope. `Many(...)` returns a vector-like value or target collection. + +### Address Normalization + +All source and target declarations normalize to an internal address: + +```julia +ObjectAddress( + scope, + kind, + species, + scale, + name, + process, + var, + relation, + multiplicity, +) +``` + +Only the compiler works with this normalized address. Users should not need to +construct it manually. + +## Object Lifecycle And Spatial Contracts + +Growth, pruning, organ creation, reparenting, and moving organs must all update +the same compiled caches: + +- object selections used by `AppliesTo`, `Inputs`, and `Calls`; +- `RefVector` or equivalent many-object carriers; +- temporal stream ownership; +- writer validation; +- environment bindings. + +The public mutation API should make cache invalidation explicit and centralized: + +```julia +register_object!(model, object; parent) +remove_object!(model, object) +reparent_object!(model, object, new_parent) +move_object!(model, object, geometry_or_position) +Advanced.refresh_bindings!(model) +``` + +Spatial environment backends should depend on a small geometry contract, not on +a particular plant representation: + +```julia +position(object_or_status) +geometry(object_or_status) +bounds(object_or_status) +``` + +Packages can provide richer geometry, octrees, voxel grids, or layers, but +PlantSimEngine should only require enough information to bind an object to an +environment provider. + +## Duplicate Writers And Updates + +Most variables should have one canonical writer per object and timestep. When a +variable is intentionally updated by several models, the scenario should say so +where the model applications are assembled: + +```julia +ModelSpec(PruningModel()) |> + AppliesTo(Many(scale=:Leaf)) |> + Updates(:leaf_biomass; after=:carbon_allocation) +``` + +`Updates(...)` should be rare and explicit. It is a scenario-level ordering +rule, because a model author cannot predict every model that will later update +the same variable. + +## Environment And Microclimate + +Meteorology should remain automatic unless a model or scenario needs special +behavior. Models declare environment variables: + +```julia +meteo_inputs_(::LeafEnergyModel) = ( + T=0.0, + Rh=0.0, + Wind=0.0, + Ri_PAR_f=0.0, + CO2=0.0, +) +``` + +The runtime resolves those variables through the model environment service. + +Default resolution: + +1. A global/table meteo backend gives every object the current meteo row. +2. A voxel, octree, layered, or grid backend samples the cell bound to the + object. +3. If the object has no position, use the parent position. +4. If no spatial binding can be made, fall back to global meteo or error when + the environment variable is required. + +Users can override the binding contract: + +```julia +EnvironmentResolver( + bind=(model, object) -> containing_cell(model.microclimate, position(object)), +) +``` + +PlantSimEngine should define the protocol and caching hooks, not the voxel or +octree implementation. Specialized packages should provide concrete spatial +backends. + +The environment backend protocol should be small and backend-oriented: + +```julia +Advanced.bind_environment(model, backend, object) +sample_environment(backend, binding, time, variables) +scatter_environment!(backend, binding, values) +refresh_environment!(backend, model) +``` + +`meteo_inputs_(model)` declares what a model reads from the active environment +provider. `meteo_outputs_(model)` declares what a model can write back to a +mutable microclimate provider. Simple global meteorology remains the default +provider. + +Scenario-level environment source remapping belongs on `Environment(...)`, for +example: + +```julia +ModelSpec(LeafGasExchange(); name=:gas_exchange) |> + AppliesTo(Many(scale=:Leaf)) |> + Environment(provider=:global, sources=(CO2=:Ca,)) +``` + +Here the model still reads `meteo.CO2` because that is its declared generic +contract, while the active environment backend samples the source variable +`:Ca`. Environment binding refresh validates source availability when the +backend can enumerate variables, and explanations report both +`required_inputs` and `source_inputs`. + +Global tabular meteorology follows the model application's compiled +`TimeStep(...)`. PlantMeteo samples the table with the reducer and window from +`meteo_hint(...)` when the model runs more slowly than the weather base step. +An `Environment(; sources=...)` override replaces only the source variable; it +does not discard the model-author reducer. The prepared weather sampler is +compiled once, and one sampled row is reused by every object targeted by the +same application at that timestep. + +Spatial or mutable backends retain control of their own temporal semantics. +PlantSimEngine supplies the compiled object/cell binding and current simulation +time; a specialized microclimate backend decides whether its local state is +instantaneous, interpolated, or internally integrated. + +### Cached Environment Bindings + +Spatial lookup must not happen for every model call. At initialization and when +objects are created, the runtime builds an environment binding cache: + +```julia +EnvironmentBinding( + object_id, + provider=:microclimate_grid, + cell_id, + variables=(:T, :Rh, :Wind, :Ri_PAR_f), +) +``` + +Runtime sampling is: + +```text +object -> cached binding -> environment cell -> current values +``` + +Invalidation events: + +- object created; +- object removed; +- object moved; +- geometry changed; +- environment grid rebuilt or refined; +- model environment requirements changed. + +Geometry APIs should provide ergonomic invalidation: + +```julia +mark_environment_binding_dirty!(model, object) +update_geometry!(object, geometry; invalidate_environment=true) +``` + +Before each timestep, dirty bindings are refreshed in batch. + +## Compilation Strategy + +The compiler should build one global dependency graph over object addresses. +The graph includes: + +- value dependencies from `Inputs(...)`; +- callable dependencies from `Calls(...)`; +- model update edges from `Updates(...)`; +- temporal policy edges; +- environment reads and writes; +- object-scope selection caches. + +The runtime representation is an implementation detail: + +- same-rate local links can stay as aliases; +- cross-rate links use temporal state; +- many-object links use `RefVector` or node-value streams; +- call links use `ModelCall` or an equivalent callable runtime handle; +- environment links use cached `EnvironmentBinding`s. + +The final execution plan should group contiguous targets with the same concrete +model, status, model-bundle, input-binding, and environment-binding types. +Dynamic dispatch may occur once at the application/batch boundary, but not for +every leaf in a homogeneous target set. Exceptional model overrides form +separate concrete batches while preserving stable object order. Lifecycle or +environment refreshes rebuild these batches before the next timestep. + +The public explanation API must describe the normalized graph, not the internal +carrier choice. + +## Agent-Facing Requirements + +The final design must be understandable by agents through structured +explanation helpers: + +```julia +explain_objects(model) +explain_instances(model) +explain_scopes(model) +explain_bindings(sim) +explain_calls(sim) +explain_environment_bindings(sim) +explain_schedule(sim) +explain_writers(sim) +explain_execution_plan(sim) +explain_output_retention(sim) +``` + +These helpers should return stable structured data, not only pretty text. A +binding row should include at least: + +- consumer application id; +- consumer object id; +- consumer variable; +- source selector; +- resolved producer application id or environment provider id; +- resolved producer object ids; +- process/name filters; +- temporal policy and window; +- carrier kind; +- copy/reference semantics; +- reason the binding was chosen; +- whether it came from inference, `dep(model)`, or `ModelSpec`. + +Execution-plan rows should additionally expose the selected object ids, +concrete model/status/carrier types, batch size, and whether the inner loop is +homogeneous and specialized. + +Output-retention rows should expose the retained application id, variable, +retention reasons, compiled retention horizon, and current target count so +agents can distinguish default retain-all behavior, requested output streams, +and bounded temporal-dependency streams. + +Errors should report concrete object labels, scope selectors, process names, +variables, and suggested fixes. + +## API Position + +This is a breaking design. It preserves model kernels and the +`run!(model, models, status, meteo, constants, extra)` contract while replacing +the scenario configuration surface with `CompositeModel`, `Object`, `ModelSpec`, +selectors, `Inputs(...)`, `Calls(...)`, `TimeStep(...)`, and +`Environment(...)`. diff --git a/docs/src/dev/composite_model_implementation_plan.md b/docs/src/dev/composite_model_implementation_plan.md new file mode 100644 index 000000000..966f084ea --- /dev/null +++ b/docs/src/dev/composite_model_implementation_plan.md @@ -0,0 +1,1043 @@ +# Unified CompositeModel/Object Implementation Plan + +This plan is the persistent handoff for replacing the historical +multiscale-mapping system with one composite-model/object address system. + +The implementation can be incremental internally, but the target API is +breaking. Do not preserve experimental intermediate APIs as user-facing +concepts in the final design. + +The target public surface should be centered on a small set of concepts: + +```julia +CompositeModel +Object +ModelSpec + +AppliesTo(...) +Inputs(...) +Calls(...) +Updates(...) +TimeStep(...) +Environment(...) +``` + +This is the API memory target for users, modelers, and agents. Additional +types should be selectors, model traits, or internal compiled carriers. + +## Implementation Progress + +- Started Phase 0 by adding the public API vocabulary as real typed metadata: + `AppliesTo(...)`, `Inputs(...)`, `Calls(...)`, `TimeStep(...)`, and + `Environment(...)` can now be applied to `ModelSpec`. +- Added `ModelSpec(model; name=...)` application names and getters: + `application_name`, `applies_to`, `value_inputs`, `model_calls`, and + `environment_config`. +- Added initial selector and address types: + `SceneScope`, `Self`, `SelfPlant`, `Ancestor`, `Scope`, `Kind`, `Species`, + `Scale`, `Relation`, `One`, `OptionalOne`, `Many`, and `ObjectAddress`. +- Added initial `CompositeModel`/`Object` registry types and lifecycle hooks: + `register_object!`, `remove_object!`, `reparent_object!`, `move_object!`, + and `Advanced.refresh_bindings!`. +- Added registry-backed selector resolution with `resolve_object_ids` and + `resolve_objects` for `SceneScope()`, `Self()`, `SelfPlant()`, + `Ancestor(...)`, `Scope(...)`, positional selectors such as + `Kind(:plant)`/`Scale(:Leaf)`, and `One`/`OptionalOne`/`Many` cardinality + checks. +- Added `explain_scopes(model)` for agent-readable scope diagnostics. It + reports the global model scope, each object subtree, each named + `Scope(...)`, and label groups by scale, kind, and species with concrete + resolved object ids. +- Selector cardinality and named-scope failures now report the consumer + context, matched object ids, requested criteria, available scales, kinds, + species, and names inside the resolved scope, plus bounded edit-distance + suggestions. +- `Relation(...)` selectors now resolve `:self`, `:parent`, `:children`, + `:ancestors`, `:descendants`, and `:siblings` relative to the consuming + object. Explicit scopes constrain relation results, default dependency scopes + do not erase parent/sibling queries, and compiled `Inputs(...)` can use these + relations without runtime selector resolution. +- `ObjectAddress(selector)` now normalizes positional `Kind`, `Species`, + `Scale`, scope, and `Relation` selectors instead of recording only keyword + criteria. +- Started the object-address compiler with `Advanced.compile_composite_model(model, specs)` and + compiled model application/binding carriers. The compiler now resolves + `AppliesTo(...)` target object ids, object-relative `Inputs(...)` source + object ids, and object-relative `Calls(...)` callee object/application ids + before runtime. +- Added `explain_applications`, `explain_bindings`, and `explain_calls` + for the compiled model view. These explanations expose application ids, + processes, target ids, input source ids, call callee ids, temporal policy, + window, and carrier hints. +- Added status-backed compiled input carriers. When source objects already + hold `Status` values, `Inputs(...)` bindings now precompile a scalar shared + `Ref`, a homogeneous `RefVector`, or an `Advanced.ObjectRefVector` fallback for + heterogeneous reference-preserving vectors. `input_carrier`, `input_value`, + and `has_reference_carrier` expose these carriers, and `explain_bindings` + reports carrier kind, copy/reference semantics, carrier type, and reference + availability. +- Added conservative same-object input inference in the model compiler. When a + model declares an `inputs_` variable that is not covered by explicit/default + `Inputs(...)`, and exactly one other application on the same object outputs + the same variable, `Advanced.compile_composite_model` creates an inferred reference binding. + `explain_bindings` now reports binding `origin` values such as + `:model_default`, `:model_spec`, and `:inferred_same_object`. +- Compiled input bindings now carry producer metadata. When an `Inputs(...)` + selector uses `process=` or `application=`, `Advanced.compile_composite_model` validates that a + matching source application exists for the selected source objects. + `explain_bindings` reports `source_application_ids`, `process`, and + `application` for agent-readable dependency diagnostics. +- Dependency selectors in `Inputs(...)` and `Calls(...)` now infer a default + scope from the consumer object when no explicit `within=...` is provided: + model objects default to `SceneScope()`, while non-model objects default to + `Self()`. Shared model/soil dependencies from organs should therefore use + `within=SceneScope()` explicitly. +- `Advanced.compile_composite_model` now validates required status inputs from `inputs_(model)`. + Each required input must either have a compiled binding or already exist on + the target object `Status`; otherwise compilation errors with the concrete + application id, object id, and input variable. +- CompositeModel compilation creates an empty `Status` for model-targeted objects when + status is omitted, inserts missing `outputs_` and `meteo_outputs_` fields + from model defaults, and inserts explicitly or implicitly bound input fields + from `inputs_` defaults. Unbound external inputs remain compilation errors. +- `Advanced.compile_composite_model` now rejects `Inputs(...)` declarations whose left-hand + variable is not declared by the target model's `inputs_`. This catches + misspelled or stale scenario bindings before they create silent unused + metadata. +- `Advanced.compile_composite_model` now validates source availability for status-backed + non-temporal `Inputs(...)` bindings. When selected source objects already + have `Status` values, the requested source variable must resolve to + references instead of silently compiling to an unused/no-op binding. +- Carrier compilation preserves source `Status` references and arbitrary value + types; tests cover scalar refs, heterogeneous many-object vectors, and a + homogeneous dual-like `BigFloat` value through `RefVector`, model arithmetic, + source mutation, and typed output publication. +- Same-object renaming is supported directly by `Inputs(...)`, for example + `Inputs(:renamed_signal => One(within=Self(), var=:signal))`. The compiler + aliases the source `Ref`, records the renamed source variable in + `explain_bindings`, and schedules the producer before the consumer. +- Same-rate input carriers are installed directly into consumer `Status` + reference cells during model compilation. Scalar bindings share the source + `Ref`; many-object bindings store the compiled `RefVector` or + `Advanced.ObjectRefVector` once. The timestep runtime performs no assignment for + these bindings, and a focused `Many(...)` materialization gate verifies zero + allocations after compilation. +- Reference wiring adds a missing bound input field to the consumer `Status` + schema when needed, instead of requiring users to duplicate compiler-owned + input placeholders. +- Added call ambiguity validation in the compiled model view: a call can select + by process when unique, and must use `application=:name` when several model + applications with the same process match the same object. +- Added a model binding cache with `Advanced.refresh_bindings!`, `Advanced.bindings_dirty`, + `Advanced.compiled_bindings`, and `Advanced.model_revision`. Object creation, removal, + and reparenting now invalidate the compiled binding cache and bump a model + revision before the next refresh. +- Added an environment binding cache with `Advanced.refresh_environment_bindings!`, + `Advanced.compile_environment_bindings`, `Advanced.CompiledEnvironmentBinding`, + `Advanced.CompiledEnvironmentBindings`, `Advanced.environment_bindings_dirty`, + `Advanced.compiled_environment_bindings`, `Advanced.environment_revision`, and + `explain_environment_bindings`. The compiler resolves each + application/object environment provider, backend, required + `meteo_inputs_`, produced `meteo_outputs_`, support descriptor, and backend + cell before runtime. +- Added the minimal model geometry contract: `geometry(object_or_status)`, + `position(object_or_status)`, and `bounds(object_or_status)`. Environment + binding refreshes now call `update_index!(backend, entities)` once per + distinct backend before `Advanced.bind_environment`, giving spatial backends a current + model-wide object/entity list for precomputed microclimate lookup. +- Automatic spatial binding now uses the nearest ancestor geometry when a + target object has no geometry of its own. Existing backends still receive an + `Object` carrying the target id/status, while its binding-time geometry comes + from the ancestor. Explanations report `geometry_source=:self`, `:ancestor`, + or `:global` and the source object id. +- Moving an object invalidates environment bindings for descendants that + inherit its geometry, stopping at descendants with their own geometry. This + preserves unaffected cached bindings. +- Environment refresh now reconciles model environment contracts against + cached spatial bindings. If only `meteo_inputs_` or `meteo_outputs_` changes + while application id, object, process, provider, backend, status, and geometry + provenance remain unchanged, required/output metadata is updated while the + cached cell is reused without `update_index!` or `Advanced.bind_environment`. +- `validate_meteo_inputs(model)` and + `validate_meteo_inputs(compiled_scene, meteo_or_backend)` now validate + composite-model/object model application `meteo_inputs_` against the active + environment or an explicit replacement. Errors report model application ids, + so duplicate process applications remain diagnosable. +- `Environment(; sources=(target=:source,))` now remaps model-facing + environment variables to backend source variables. Environment binding + refresh validates missing source variables when the backend can enumerate its + variables, and `explain_environment_bindings` reports both `required_inputs` + and `source_inputs`. +- CompositeModel applications now infer model-author default environment source remaps + from `meteo_hint(...).bindings` when the scenario does not provide explicit + meteo bindings. Scenario `Environment(; sources=...)` keeps precedence over + the trait. +- Global tabular meteorology is now sampled at each model application's + compiled clock. `meteo_hint(...).bindings` reducers and windows are applied + through PlantMeteo, while `Environment(; sources=...)` replaces only the + source variable and preserves the selected reducer. Prepared weather + samplers are shared by applications using the same weather table, and each + application/timestep sample is cached once per run so all target objects + reuse it. +- Object creation, removal, and reparenting invalidate both structural and + environment bindings. Object movement invalidates only environment bindings, + so moving a leaf or changing its geometry can refresh microclimate lookup + without rebuilding object/model binding carriers. +- Added public geometry invalidation helpers: + `update_geometry!(model, object, geometry; invalidate_environment=true)` + and object-scoped `mark_environment_binding_dirty!(model, object)`. + Geometry-only changes record the affected object ids and refresh only their + compiled environment bindings; descendants inheriting moved ancestor + geometry are invalidated as part of the same object-scoped refresh. +- Started composite-model/object execution with `run!(model; steps=...)`. + The runtime refreshes compiled object bindings and environment bindings, + materializes precompiled `Inputs(...)` carriers into consumer `Status` + fields, samples the bound environment backend, and calls generic model + kernels through the existing `run!` contract. +- CompositeModel/object execution now publishes model outputs to model-local temporal + streams. Compiled `Inputs(...)` bindings marked as `:temporal_stream` can + materialize `HoldLast`, `Interpolate`, `Integrate`, and `Aggregate` values + before the consumer runs, using selector source ids, source variables, + windows, and the model base timestep. +- CompositeModel temporal `Inputs(...)` now honor producer `output_policy(...)` traits + when the selector omits `policy=...` and resolves to a unique source + application. Explicit selector policies remain scenario-level overrides. +- CompositeModel `Interpolate(...)` matches the established multirate runtime: + bracketed samples use linear interpolation, online consumers use linear + extrapolation from the last two samples when requested, and insufficient or + non-interpolable values fall back to hold-last. `mode=:hold` and + `extrapolation=:hold` are supported, invalid modes fail during model + compilation, and interpolation arithmetic preserves generic numeric value + types without converting model values to `Float64`. +- Unified `Inputs(...)` now supports explicit lagged dependencies with + `PreviousTimeStep(:input) => selector`. Lagged bindings use temporal streams, + read source samples at or before `t - 1`, preserve the initialized consumer + status value until history exists, and do not add a same-timestep scheduling + edge. This allows feedback cycles to compile without changing generic model + kernels. +- CompositeModel/object execution now scatters mutable environment outputs declared by + `meteo_outputs_(model)` back to the bound backend after each model call. + This reuses `scatter_environment_outputs!`, so environment writers keep the + existing generic model contract: compute a same-named status value, and let + the runtime push it to the active microclimate backend. +- Added root application scheduling from `TimeStep(...)` using `Dates.Period` + values and the model environment base step. `explain_schedule` on a + `Advanced.CompiledCompositeModel` now reports each application clock, phase, timestep in base + steps, timestep duration in seconds, and whether the application is scheduled + as a root application or is manual-call-only. +- CompositeModel application scheduling now also honors a model's `timespec(...)` trait + when `TimeStep(...)` is omitted. Scenario-level `TimeStep(...)` keeps + precedence over the model trait, matching the established multirate runtime. +- CompositeModel application scheduling now validates `timestep_hint(...)` required + bounds for base-step-derived clocks. Hints remain compatibility constraints; + they do not override explicit `TimeStep(...)` or non-default `timespec(...)`. +- `Advanced.compile_composite_model` now computes a stable topological application order from + resolved `Inputs(...)` producer edges and `Updates(...)` writer-order edges. + Inputs produced by manual-call-only applications are redirected to the parent + application that owns the `Calls(...)` call stack. `run!(model)` uses this + precompiled order instead of user declaration order, cycles fail at compile + time, and `explain_schedule` reports `execution_index`. +- `Advanced.CompiledCompositeModel` now pre-indexes input and call bindings by + `(application_id, object_id)`. Per-object input materialization and + `call_targets` lookup uses these indexes instead of scanning every + binding in the model at each model call. +- `Advanced.CompiledCompositeModel` now also pre-indexes applications by application id. + Hard-call target resolution and stable ordered-application materialization + use this index instead of scanning or rebuilding lookup dictionaries. +- `Advanced.CompiledEnvironmentBindings` now pre-indexes environment bindings by + `(application_id, object_id)`. Environment sampling and mutable environment + output scattering use direct lookup instead of scanning all environment + bindings for every model invocation. +- Added `RunContext` and `CallTarget`. Models can retrieve manual + `Calls(...)` targets with `call_targets(extra, :name)` and execute + them with `run_call!`, preserving explicit call-stack control in the + composite-model/object runtime. Manual calls execute immediately under the parent call + stack; applications selected by `Calls(...)` are skipped by the root + `run!(model)` loop and only execute through `run_call!`. +- Added composite-model/object duplicate-writer validation. During `Advanced.compile_composite_model`, each + `(object, output variable)` now has one canonical writer unless later + writers declare `Updates(:var; after=...)`. The `after` token can match a + previous application id/name or process, so scenario authors can express + cases such as pruning after carbon allocation without changing either model + implementation. +- Added `explain_writers(compiled)`. It reports each object/variable writer + group, duplicate-writer status, writer application ids/processes, and the + `Updates(...)` declarations used to validate ordered updates. +- Extended `explain_model_specs` rows with application name, target selector, + value inputs, manual calls, and environment metadata. +- Started Phase 3 by compiling simple `Inputs(...)` declarations to typed + scale/variable carriers, for example + `Inputs(:x => Many(scale=:Leaf, var=:y))`. +- Added model-level `Input(...)` defaults from `dep(model)` into + `ModelSpec` value inputs. Scenario-level `ModelSpec(...) |> Inputs(...)` + overrides those defaults before the native binding is compiled. +- Removed the intermediate scenario bridge after the composite-model/object compiler + gained native `Inputs(...)` support. Manual value-transfer carriers are not + retained as user-authored API. +- Removed the intermediate dependency resolver after `Calls(...)` became + native composite-model/object metadata. Manual model execution now goes through + `CallTargets`, `call_targets`, and `run_call!`. +- Added model-level `Call(...)` defaults from `dep(model)` into + `ModelSpec` manual-call metadata. Scenario-level + `ModelSpec(...) |> Calls(...)` overrides those defaults, and + `dep(::ModelSpec)` excludes raw `Call(...)` trait entries so default calls + are normalized through the same bridge as explicit calls. +- Migrated the MAESPA example's model energy-balance hard calls to + scenario-level `ModelSpec(scene_model) |> Calls(...)`. +- Migrated the MAESPA example's model LAI leaf-area transfer to consumer-side + `ModelSpec(LAIModel(...)) |> Inputs(...)`. +- Started Phase 5 with `CompositeModelTemplate` and `ObjectInstance`. A template stores + reusable composite-model/object `ModelSpec`s plus default object labels, and an + instance mounts those specs inside one named object subtree. +- `CompositeModel(...)` accepts `ObjectInstance` values directly or through its + `instances` keyword. An instance root can be an owned `Object` or the id of + an object supplied separately to the model. +- Mounted template applications receive stable instance-prefixed application + names and an implicit `Scope(instance_name)` on unqualified + `AppliesTo(...)` selectors. Their `Inputs(...)`, `Calls(...)`, scheduling, + writer validation, and execution use the normal compiled composite-model/object path. +- Instance overrides can replace one template application by application name + or process. Overrides must be unambiguous and preserve process identity. + Instances without overrides retain the exact shared model object from the + template. +- Template labels fill missing `kind` and `species` metadata throughout the + mounted subtree, while the root receives the instance name used by + `Scope(...)`. Tests cover four instances, plant-local aggregation, shared + model storage, and one process-level model override. +- Added explicit exceptional-organ overrides with + `Override(object=..., application=... or process=..., model=...)` through + `ObjectInstance(...; object_overrides=...)`. The override must resolve to one + template application, belong to the instance subtree, and preserve process, + input, output, and environment-variable declarations. +- Object overrides remain one logical model application: the compiler stores + the selected replacement model by target object id. Dependency bindings, + writer ownership, application names, and manual calls therefore remain + unchanged, and no selector resolution occurs in the runtime loop. +- Parameter/model ownership is explicit. Templates retain user-supplied model + and `parameters` objects by reference; unchanged instances share them. + Instance and object overrides retain their user-supplied replacement model + by reference. PlantSimEngine does not copy models or mutate model fields to + merge parameter overrides. +- Same-concrete-type object overrides use a concretely typed object-to-model + table. Structured application explanations report shared/per-object storage, + concrete versus heterogeneous dispatch, overridden object ids, and model + types. +- `CompositeModel` retains mounted instance metadata and `explain_instances(model)` + reports each instance root, current subtree object ids, mounted application + ids, instance/object overrides, template labels, and parameter ownership. + `explain_objects(model)` also reports instance membership. +- New objects registered below an instance automatically inherit missing + template `kind` and `species` labels. Instance explanations derive membership + from the current topology, so growth, pruning, and reparenting do not leave a + separate stale membership list. +- CompositeModel hard calls now accept explicit local meteorology: + `run_call!(target; meteo=local_meteo, publish=false)`. The callee receives + the supplied meteo object instead of resampling the bound environment, while + `publish=false` still suppresses output publication and environment + scattering. This supports iterative microclimate solvers such as the MAESPA + model energy-balance loop. +- CompositeModel runtime now builds a small process-keyed `models` bundle from compiled + `Calls(...)` edges before invoking a model kernel. This lets existing generic + hard-dependency kernels such as `Monteith` continue to call + `models.photosynthesis`, and lets `Fvcb` call + `models.stomatal_conductance`, without wrapping or rewriting the model + implementation. +- CompositeModel duplicate-writer validation now ignores manual-call-only applications + when validating canonical root writers. This keeps hard-dependency children + from being treated as independent root writers when they intentionally update + the same object status inside their parent call stack. +- Added a unified composite-model/object MAESPA example path: + `build_maespa_scene(...)` and `run_maespa_example(...)`. + It uses `CompositeModelTemplate`, `ObjectInstance`, `AppliesTo`, `Inputs`, `Calls`, + and `TimeStep(Dates.Period)` with two plant species, one shared soil object, + model LAI, and model energy balance. +- `test/test-maespa-model-example.jl` verifies the unified composite-model/object + MAESPA path. +- `run!(model)` now returns a `Simulation` wrapper with the mutated + `CompositeModel`, compiled model bindings, compiled environment bindings, and the + model-local temporal output streams. This keeps existing status mutation + behavior but makes model outputs inspectable after a run. +- Added `outputs(sim::Simulation)`, `collect_outputs(sim)`, and + `explain_outputs(sim)` for composite-model/object runs. The explanation reports object + ids, variables, publishing application ids, sample counts, time bounds, and + value types. +- `run!(model; tracked_outputs=...)` now accepts `OutputRequest` and returns + requested model outputs through `collect_outputs(sim)` or + `collect_outputs(sim, :request_name)`. CompositeModel requests are materialized from + retained typed temporal streams after the run. They support `HoldLast`, + `Interpolate`, `Integrate`, and `Aggregate`, `Dates.Period` export clocks, + canonical-publisher inference when unique, explicit `process=...` + selection, and dynamic objects over each object's own published sample + interval. +- CompositeModel output requests now compile a publisher-level retention plan. With + `tracked_outputs=nothing`, the model runtime retains all output streams for + historical inspection. With explicit `tracked_outputs`, including an empty + request vector, it retains only requested publisher streams plus streams + required by temporal `Inputs(...)`. Dependency-only streams are pruned after + publication to the compiled policy horizon: latest-only for `HoldLast`, the + input window for `Integrate`/`Aggregate`, and enough source history for + `Interpolate`/`PreviousTimeStep`. Explicitly requested streams retain their + complete histories for post-run export. Export is therefore not yet fully + online, but unrequested temporal dependencies no longer grow for the full + simulation. +- Added `explain_output_retention(sim)` to report which application/variable + streams are retained and whether the reason is default retention, an output + request, or a temporal dependency. Dependency-only rows also report their + compiled `retention_steps`; unbounded requested/default rows report + `nothing`. +- CompositeModel temporal streams are now keyed by application id, object id, and + variable. Multiple applications can publish the same variable on the same + object without overwriting each other's stream samples. +- CompositeModel output-export tests now cover requested-output `DataFrame` + materialization, canonical publisher inference when `process=` is omitted, + rejection when only stream-only publishers exist, and ambiguity when an + explicit process matches both a stream-only and a canonical publisher. +- CompositeModel `OutputRequest(...)` now accepts `application=...` to select an + explicit application id/name when the same process is mounted more than + once. Explicit application selection can retain and export a + `:stream_only` publisher. +- Each model temporal stream owns a concrete `Vector{Tuple{Float64,T}}` + selected from its first published value rather than boxing all values as + `Any`. Output type changes fail explicitly, and `Interpolate`/`Integrate` + tests verify `BigFloat` histories and reduced values remain `BigFloat`. +- CompositeModel `OutputRouting(; var=:stream_only)` now matches the unified graph + semantics: stream-only outputs are excluded from canonical writer validation + and same-object input inference, while remaining available in output streams + and explicit `Inputs(..., application=:name)` selections. +- `run!(model; steps=...)` now refreshes dirty structural bindings at timestep + boundaries. Objects created, removed, or reparented by a model during one + timestep update `AppliesTo(...)` target sets, input carriers, call targets, + writer validation, and scheduling before the next timestep. +- Geometry-only mutations refresh environment bindings at the next timestep + without recompiling structural bindings. The returned `Simulation` + always contains final compiled structural and environment bindings, including + mutations performed on the last step. +- Environment dirty tracking is now object-scoped for geometry-only changes. + `move_object!`, `update_geometry!`, and + `mark_environment_binding_dirty!(model, object)` retain unaffected compiled + bindings and re-run `Advanced.bind_environment` only for applications targeting the + changed object. Structural changes and provider-wide invalidation still + rebuild the complete environment cache. +- Runtime lifecycle tests cover a model-created leaf joining a leaf + application and plant-local `RefVector`, a pruned leaf leaving both before + the next step, and a moved leaf switching mock microclimate cells. +- `Advanced.CompiledCompositeModel` now precompiles a process-keyed model bundle for every + `(application_id, object_id)` target. Generic hard-dependency kernels receive + this cached bundle through their existing `models` argument without + traversing `Calls(...)` or allocating temporary collections in the timestep + hot loop. +- Added `explain_model_bundles(compiled)` to report the process names and model + types available to every application/object kernel call. Tests verify + zero-allocation repeated bundle lookup and the MAESPA leaf bundle + `energy_balance -> photosynthesis -> stomatal_conductance`. +- Root model execution now compiles contiguous homogeneous target batches. + Each target prebinds its concrete model, `Status`, process-keyed model bundle, + input-binding tuple, and environment binding. Runtime dispatch occurs once + at the batch function barrier; the inner object loop is specialized on a + concrete target type. +- Exceptional object overrides with another concrete model implementation + become separate batches without changing stable object execution order. + Structural or environment binding refresh recompiles the execution plan + before the next timestep. +- Added `explain_execution_plan(scene_or_simulation)`. It reports batch object + ids, concrete model/status/carrier types, batch sizes, and inner-loop dispatch + semantics. A focused 128-leaf gate verifies zero allocations inside a warmed + homogeneous no-output batch. +- Added `objects_from_mtg(root; ...)` and `CompositeModel(root::MultiScaleTreeGraph.Node; + ...)`. Existing MTG topology is traversed once into the unified registry, + preserving stable node-derived ids, parent relations, labels, geometry, and + existing `:plantsimengine_status` objects through configurable accessors. + +The composite-model/object compiler is executable: selectors normalize to object +addresses, resolve before runtime, and compile into reference, temporal, call, +writer, and environment carriers. The historical mapping compiler has been +removed. + +## Phase 0: Public Contract Freeze + +Goal: decide the small public vocabulary before implementing internals. + +Define: + +- `ModelSpec(model; name=nothing)` as the model-application wrapper. +- `AppliesTo(selector)` as the target object-set declaration. +- `Inputs(...)` for value dependencies. +- `Calls(...)` for manual call-stack dependencies. +- `Updates(...)` for rare ordered duplicate writers. +- `TimeStep(period::Dates.Period)` and related multirate policies. +- `Environment(...)` for optional environment resolver/backend overrides. + +Rules: + +- a model kernel remains generic and declares `inputs_`, `outputs_`, optional + `dep`, optional `meteo_inputs_`/`meteo_outputs_`, and `run!`; +- a model application decides where the kernel runs, at what rate, and how its + inputs, calls, updates, outputs, and environment are bound; +- application ids are stable and can be generated from explicit `name`, + process, object selector, and occurrence index; +- if several applications provide the same process on the same object set, + selectors must disambiguate by application name or another explicit filter. + +Acceptance tests: + +- a model can be applied twice to the same leaf objects with different names; +- a dependency selector can choose by process when unique and by name when not; +- structured explanations expose model kernel type, process, application name, + and target object ids. + +## Phase 1: CompositeModel Object Registry + +Goal: introduce the internal object model without changing public behavior yet. + +Implement: + +- `ObjectId` as the stable identity key for every runtime object. +- `ModelObject` metadata with labels: + `scale`, `kind`, `species`, optional `name`, parent id, child ids, and + optional geometry/position handle. +- `Advanced.ObjectRegistry` storing objects, parent/child relations, and indexes by + label. +- adapters from existing MTG state into the registry: + each selected root and each MTG node gets an object id; + single-status simulations get one object with `scale=:Default`. +- object lifecycle hooks for add/remove/reparent that mirror the existing MTG + runtime reindexing. + +Acceptance tests: + +- the MAESPA example registers five leaf objects, two plant objects, one soil + object, and one model object; +- `status(sim, :plant_A, :Leaf)` and `status(sim, :Leaf)` can be expressed as + registry queries; +- add/remove/reparent updates object registry relations and status views. + +## Phase 2: Selector And Scope Language + +Goal: make "which objects?" explicit and reusable. + +Implement selector types: + +```julia +SceneScope() +Self() +SelfPlant() +Ancestor(scale=:Plant) +Scope(name) +Kind(kind) +Species(species) +Scale(scale) +Relation(...) +``` + +Implement multiplicity wrappers: + +```julia +One(selector...) +OptionalOne(selector...) +Many(selector...) +``` + +Selectors must normalize to `ObjectAddress` objects with enough context to be +resolved relative to a consuming object. + +Implement `AppliesTo(...)` using the same selector system. The target object +set of a model application must never be hidden inside a mapping key or +implicit scale table. + +Definitions: + +- `Self()` means only the current model application object. +- `Subtree()` means the current object and its descendants. +- `SelfPlant()` means the nearest containing plant scope. +- `Ancestor(scale=:Plant)` is the generic selector form for `SelfPlant()`. +- `SceneScope()` means the whole model. +- `Scope(name)` means a named scope or object collection. + +Rules: + +- unqualified selectors inside a reusable plant application bundle default to + `within=Self()`; +- model-level selectors default to `within=SceneScope()`; +- `One(...)` errors unless exactly one object resolves per consumer; +- `Many(...)` preserves stable object-id order; +- object-id order replaces incidental traversal order as the semantic default. +- selectors are resolved during compilation or binding refresh, not inside the + inner model loop. + +Acceptance tests: + +- plant allocation on four oil palms reads only leaves under each plant; +- model LAI reads leaves across all plant objects; +- a species-specific model model can read only `species=:oil_palm` leaves; +- a model application target set declared with `AppliesTo(...)` produces stable + application/object pairs; +- selector errors report available labels and near matches. + +## Phase 3: Unified Value Inputs + +Goal: use `Inputs(...)` as the only user-facing value-dependency declaration. +Historical `MultiScaleModel(...)` mappings are migration sources only. + +Target API: + +```julia +ModelSpec(AllocationModel()) |> + AppliesTo(Many(kind=:plant, scale=:Plant)) |> + Inputs(:leaf_carbon => Many(scale=:Leaf, within=Subtree(), var=:leaf_carbon)) + +ModelSpec(LAIModel(area)) |> + AppliesTo(One(scale=:Scene)) |> + Inputs(:leaf_areas => Many(kind=:plant, scale=:Leaf, within=SceneScope(), var=:leaf_area)) +``` + +Implement: + +- `Inputs(...)` as `ModelSpec` configuration. +- `Input(...)` or an equivalent internal wrapper that lets `dep(model)` + provide default value-input bindings. +- normalized input bindings from target variable to `ObjectAddress`. +- compiler pass that decides carrier: + direct reference, `RefVector`, temporal stream, or materialization. +- status-default insertion for materialized target variables using the + consumer model's `inputs_` default. +- temporal policies on value inputs: + `HoldLast`, `Interpolate`, `Integrate`, `Aggregate`. +- `Dates.Period` windows on value inputs, for example `window=Day(1)`. +- copy/reference semantics reporting for every compiled input binding. + +Rules: + +- model authors still declare `inputs_`; scenario authors decide where those + inputs come from; +- `dep(model)` may provide defaults for common value-input bindings in + composite-model/object composition; +- scenario-level `ModelSpec(...) |> Inputs(...)` always wins over `dep(model)` + defaults; +- same-rate local links should keep reference semantics where possible; +- cross-rate links always go through temporal state; +- duplicate source candidates are errors unless the selector disambiguates; +- materialization carriers, when needed, are internal compiler details + and are not user-authored structs; +- same-rate scalar and many-object links should avoid copies when they can use + aliases, shared refs, `RefVector`, or an equivalent typed carrier; +- PlantSimEngine must preserve arbitrary value types, including units, + automatic differentiation numbers, uncertainty wrappers, and other + numeric-like values. + +Carrier expectations: + +| Binding kind | Runtime carrier | +| --- | --- | +| same-rate scalar | shared `Ref` or local alias | +| same-rate many-object | `RefVector` or equivalent typed reference collection | +| cross-rate | temporal stream sample | +| integrate/aggregate | temporal window reduction | +| materialized cross-object input | generated pre-run status assignment | +| environment | cached environment binding sample | + +Acceptance tests: + +- the MAESPA model LAI cross-object input is declared with `Inputs(...)` and + produces the same `lai` and `leaf_area`; +- historical plant allocation `MultiScaleModel([:leaf_carbon => [:Leaf => :leaf_carbon]])` + becomes `Inputs(...)` and remains plant-local; +- a same-scale rename currently expressed with `SameScale()` works through + `Inputs(...)`; +- multi-rate value inputs integrate object streams by object id. +- same-rate many-object bindings do not allocate per timestep in a benchmarked + hot loop beyond unavoidable model work. +- unitful or dual-number status values survive `Inputs(...)` without forced + conversion to `Float64`. + +## Phase 4: Unified Model Calls + +Goal: use `Calls(...)` as the only user-facing manual model-call declaration. +The same mechanism must also be usable from `dep(model)` so hard-dependency +traits become default call declarations. + +Target API: + +```julia +ModelSpec(SceneEB()) |> + AppliesTo(One(scale=:Scene)) |> + Calls(:leaf_energy => Many(kind=:plant, scale=:Leaf, process=:energy_balance)) |> + Calls(:soil => One(kind=:soil, application=:soil_water)) +``` + +Implement: + +- `Calls(...)` as `ModelSpec` configuration. +- `Call(...)` or an equivalent internal wrapper that lets `dep(model)` provide + default manual-call dependencies. +- call resolution from `ObjectAddress` to concrete `ModelCall` handles, or an + equivalent callable runtime object if the final internal type name differs. +- same-status hard dependency calls using the same public API. +- publication semantics: + trial `run_call!(call)` mutates status only; + final `run_call!(call; publish=true)` appends outputs and temporal + streams. +- structured call explanations with parent application id, selected callee + application ids, selected object ids, selector, and publication behavior. + +Rules: + +- calls are manual call-stack dependencies and are not independently + scheduled under the parent; +- `dep(model)` call defaults are model-author defaults, not final wiring; +- scenario-level `ModelSpec(...) |> Calls(...)` overrides `dep(model)` defaults; +- hard target outputs still participate in dependency graph compilation through + the owning parent when needed; +- call selection must be visible through explanation helpers. + +Acceptance tests: + +- MAESPA model energy balance uses `Calls(...)` and still controls iterative + leaf energy calls; +- missing call selectors report `kind`, `scale`, `process`, and available + matches; +- final accepted calls publish exactly once per timestep. +- an iterative model model can run selected leaf and soil calls several times + with `publish=false` and publish only the accepted state. + +Implemented: + +- `run_call!(::CallTarget)` defaults to `publish=false`, matching the + iterative manual-call contract. +- One-shot accepted calls use `publish=true` explicitly. +- An iterative hard-call regression executes two default non-publishing trials + followed by one accepted call and verifies exactly one environment write and + one temporal output sample for the accepted state. +- `explain_calls(compiled)` reports + `publication_policy=:explicit_accept`, `default_publish=false`, and + `accepted_publish=true` for every compiled call edge. +- `ModelSpec` now retains per-binding provenance for value inputs and manual + calls. Bindings from `dep(model)` are reported as `:model_default`, + scenario-level `Inputs(...)` and `Calls(...)` are reported as `:model_spec`, + and compiler-created same-object value links are reported as + `:inferred_same_object`. `explain_bindings`, `explain_calls`, and + `explain_model_specs` expose these origins for agent-readable diagnostics. +- Zero-match `OptionalOne(...)` dependencies remain compiled and visible. + Optional inputs retain the consumer `inputs_` default with + `carrier_kind=:optional_default`; optional calls expose an empty target set + and `resolved=false` instead of failing compilation. + +## Phase 5: Object Templates, Instances, And Overrides + +Goal: support several plants of the same species with shared default models and +selective per-instance differences. + +Target API: + +```julia +oil_palm = CompositeModelTemplate( + kind=:plant, + species=:oil_palm, + mapping=oil_palm_mapping, +) + +model = CompositeModel( + ObjectInstance(:palm_1, oil_palm; root=node1), + ObjectInstance(:palm_2, oil_palm; root=node2, overrides=( + stomatal_conductance = Tuzet(; g1=3.2), + )), +) +``` + +Implement: + +- template-level model specs and parameters; +- instance-level model/parameter overrides by process; +- object-level overrides for exceptional organs; +- conflict validation when two overrides target the same process/object. +- shared parameter/model storage when template instances do not override + anything, with explicit copy/ownership behavior when they do. + +Rules: + +- templates do not prescribe topology; they attach mappings to whatever object + tree the instance provides; +- default `Self()` selectors resolve inside the current instance; +- model-wide models must opt into wider scope. + +Acceptance tests: + +- four oil palm instances share model objects/parameters when not overridden; +- one palm instance can override one process parameter; +- allocation remains per plant while model LAI sees all leaves. + +MAESPA status: + +- the unified composite-model/object MAESPA path uses `CompositeModelTemplate` and + `ObjectInstance` for species A and B. + +## Phase 5B: Object Lifecycle And Cache Invalidation + +Goal: make growth, pruning, and moving organs update every compiled binding +through one mutation path. + +Implement public lifecycle hooks: + +```julia +register_object!(model, object; parent) +remove_object!(model, object) +reparent_object!(model, object, new_parent) +move_object!(model, object, geometry_or_position) +Advanced.refresh_bindings!(model) +``` + +Implement invalidation for: + +- object selector caches; +- model application target sets; +- `RefVector` or equivalent many-object carriers; +- temporal stream ownership; +- writer validation; +- environment bindings. + +Rules: + +- topology and geometry changes do not silently leave stale carriers; +- object creation should bind the new object to model applications selected by + `AppliesTo(...)` before the next timestep; +- moving an object should refresh environment bindings without rebuilding + unrelated model bindings unless the move changes object relations or labels. + +Acceptance tests: + +- creating a new leaf adds it to plant-local allocation and model LAI before + the next timestep; +- pruning/removing a leaf removes it from many-object carriers and temporal + stream ownership; +- changing a leaf insertion angle can refresh only the affected environment + binding when topology is unchanged. + +## Phase 6: Environment Binding Cache + +Goal: make meteo/microclimate automatic and fast. + +Implement: + +- `EnvironmentBinding` cache: + object id, backend/provider id, cell/layer id, required variables. +- default environment resolver: + global meteo for non-spatial backends; + object position for spatial backends; + parent position fallback; + global fallback or validation error. +- dirty flags and batched refresh: + `mark_environment_binding_dirty!`; + `update_geometry!(...; invalidate_environment=true)`; + automatic dirty marking on object creation, removal, reparenting, and + environment grid rebuild. +- explanation helper: + `explain_environment_bindings(sim)`. +- minimal geometry accessors or traits: + `position`, `geometry`, and `bounds`. +- backend protocol: + `Advanced.bind_environment`, `sample_environment`, `scatter_environment!`, and + `refresh_environment!`. +- `Environment(...)` overrides for scenario-specific resolver/backend choices. +- `Environment(; sources=(CO2=:Ca,))` for scenario-specific environment source + remapping without changing model kernels. + +Runtime rule: + +```text +object -> cached binding -> backend cell/layer -> current meteo values +``` + +Spatial lookup must happen only during binding refresh, not inside every model +call. + +Acceptance tests: + +- global meteo gives the same values to all objects; +- missing global meteo variables fail during environment binding refresh when + the backend can enumerate variables; +- `Environment(; sources=...)` remaps backend variables to model-facing + `meteo_inputs_` names and is visible in explanations; +- a model running every two hours over hourly global meteorology receives a + windowed weather sample rather than only the current raw row; +- model `meteo_hint` reducers/windows are honored, and an + `Environment(; sources=...)` override changes the source without discarding + the reducer; +- all objects targeted by one application reuse one global weather sample per + application/timestep; +- mock grid backend binds leaves to cells once at initialization; +- moving one leaf marks only that leaf binding dirty and refreshes it before + the next timestep; +- model `meteo_inputs_` changes update required variables without recomputing + spatial links unless necessary. +- `meteo_outputs_` can write mutable microclimate variables back to the active + backend through `scatter_environment!`. + +## Phase 7: Compiler, Scheduler, And Explanation Cleanup + +Goal: make the unified graph the source of truth. + +Implement: + +- one compiler that builds a global dependency graph over object addresses; +- materialization and multiscale reference wiring as internal carriers; +- object/scope dependency scheduling; +- writer validation through the same graph, including `Updates(...)`; +- model application scheduling from `AppliesTo(...)` target sets; +- multirate scheduling based on `Dates.Period` values in `TimeStep(...)` and + input windows; +- typed compiled bindings that avoid selector resolution in timestep hot loops; +- typed homogeneous execution batches that move dynamic dispatch outside the + per-object inner loop while preserving ordered heterogeneous overrides; +- arbitrary value type preservation through status, input carriers, temporal + storage, and environment samples; +- structured explanation: + `explain_objects`, `explain_scopes`, `explain_bindings`, + `explain_calls`, `explain_environment_bindings`, `explain_schedule`, + `explain_writers`. + +Acceptance tests: + +- old `MultiScaleModel` examples rewritten with `Inputs(...)` produce matching + outputs; +- historical cross-object examples rewritten with `Inputs(...)` produce + matching outputs; +- MAESPA hard-call example rewritten with `Calls(...)` produces matching + outputs; +- explanation helpers include enough concrete object ids, scales, processes, + and variables for an AI agent to repair bad mappings. +- `explain_bindings(sim)` reports whether each dependency came from inference, + `dep(model)`, or `ModelSpec`, and reports carrier/copy semantics. +- no selector resolution occurs inside the per-object, per-model timestep loop + for static composite models. +- a warmed homogeneous execution batch performs no allocations beyond model, + output-stream, or backend work requested by the application itself; +- multirate simulations use the same object-address graph as same-rate + simulations. + +## Phase 8: Breaking API Removal And Migration Docs + +Goal: remove the old configuration surface once parity is proven. + +Removed: + +- `MultiScaleModel(...)` as public scenario configuration; +- superseded scenario containers and value-transfer authoring; +- superseded manual-dependency selectors. + +Write migration notes: + +- `MultiScaleModel([:x => [:Leaf => :y]])` -> `Inputs(:x => Many(scale=:Leaf, var=:y))`; +- cross-object value declarations -> consumer `Inputs(...)`; +- manual dependency declarations -> `Calls(...)`; +- repeated species assemblies -> `CompositeModelTemplate` plus `ObjectInstance`; +- explicit meteo wiring -> environment resolver/binding backend. +- `InputBindings(...)` -> source and temporal policy information inside + `Inputs(...)`; +- `MeteoBindings(...)` and `MeteoWindow(...)` -> `Environment(...)` and + environment sampling/window policy; +- `OutputRouting(...)` -> model-application output policy; +- `PreviousTimeStep(...)` -> temporal policy/cycle-breaking marker in the + unified graph; +- `ScopeModel(...)` -> `AppliesTo(...)` plus selector scope. + +Regression tests must cover all migrated examples before removal. + +Migration documentation progress: + +- Added `docs/src/migration_composite_model.md` with direct translations for + `MultiScaleModel`, repeated object assemblies, + `TimeStepModel`, `InputBindings`, `MeteoBindings`, `ScopeModel`, and + `SameScale`. +- Documentation navigation and the home page now identify the composite-model/object API + as the target for new multiscale and multi-plant work. +- The documentation home page now uses executable composite-model/object examples as the + primary quickstart. It shows `CompositeModel`, `Object`, `ModelSpec`, `AppliesTo`, + `Inputs`, `TimeStep`, automatic same-object binding inference, multi-object + `Many(...)` inputs, and manual `Calls(...)` syntax. +- The repository README now mirrors the composite-model/object entry point instead of + teaching `ModelMapping` first. It includes smoke-tested `CompositeModel`/`Object` + quickstart code, `Inputs(...)` multi-object coupling, conceptual + `Calls(...)` syntax, and links to the migration guide. +- Added `docs/src/composite_model/quickstart.md` as the first native + composite-model/object tutorial page and promoted it in the documentation navigation. + The page contains docs-tested examples for one-object model chaining, + inferred same-object bindings, `OutputRequest` retention, multi-object + `Inputs(...)`, `RefVector` carrier explanations, and manual `Calls(...)` + syntax. +- The repository agent skill teaches the unified public vocabulary. +- The public API page now starts with curated composite-model/object groups for scenario + construction, selectors, coupling, lifecycle, environment, runtime, and + structured explanations. + +Current removal audit: + +- The unreleased intermediate scenario and runtime subsystem has been deleted, + including its carriers, dependency selectors, target helpers, tests, + examples, and documentation. +- Public manual-call control now uses vector-like `CallTargets`, + `run_call!(context, name)`, `call_targets`, and `run_call!(target)`. +- `RunContext` defines Symbol-named `run_call!` and `call_targets` directly. +- The legacy mapping transforms are removed: + `MultiScaleModel`, `SameScale`, `TimeStepModel`, `InputBindings`, + `MeteoBindings`, `MeteoWindow`, and `ScopeModel` are not retained as + compatibility constructors. +- `ModelMapping` is removed. Retained documentation mentions it only as + historical migration context. +- Historical MTG mapping and mapping-level multirate pages were removed from + the active documentation navigation. A future documentation cleanup can + replace + these historical pages with equivalent composite-model/object tutorials rather than + retaining them as migration reference. +- The model execution page has been rewritten as a composite-model/object-first guide. + It now documents compilation, same-rate reference carriers, temporal + `Inputs(...)`, manual `Calls(...)`, `Updates(...)`, `TimeStep(...)`, + environment binding, output retention, lifecycle cache invalidation, and + compatibility translations from the historical mapping runtime. +- The detailed first simulation tutorial now starts from the composite-model/object API + instead of `ModelMapping`. It introduces model kernels, object status, + compiled applications, inferred same-object bindings, model outputs, and a + short compatibility note for historical mapping examples. +- The quick examples page now uses copy-pasteable composite-model/object examples for + Beer light interception, degree-days/LAI/light coupling, biomass growth, and + retained `OutputRequest` exports. `ModelMapping` appears only in the + compatibility note. +- The standard model coupling, model switching, and coupling more complex + models step-by-step tutorials now teach `CompositeModel`, `Object`, `ModelSpec`, + `AppliesTo`, `TimeStep`, inferred soft `Inputs(...)`, and manual + `Calls(...)` first. Historical `PlantSimEngine.ModelMapping(...)` appears + only in compatibility notes on those pages. +- The home page has been replaced by native composite-model/object examples. The + repository README has also been replaced by native composite-model/object examples, + and a dedicated composite-model/object quickstart is now available in the main + documentation navigation. +- CompositeModel/object tests cover scheduling, temporal policies, binding inference + and overrides, meteo contracts and aggregation, output routing and + application-qualified export, and structured explanations. Legacy mapping + regression tests were removed with the old runtime. +- CompositeModel/object tests now include public meteo-contract validation parity for + missing environment variables, explicit `Environment(; sources=...)` + remapping, model-author `meteo_hint` source defaults, and validation against + an explicit replacement meteo object/backend. +- Test code uses the canonical `TimeStep(...)` spelling and composite-model/object + modifiers. Legacy transform tests were removed with the old compatibility + constructors. +- The unified MAESPA path is implemented and tested through `AppliesTo`, + `Inputs`, `Calls`, `call_targets`, and `run_call!`. + +## Resolved API Decisions + +- `SceneScope`, `Self`, `SelfPlant`, `Kind`, `Species`, and `Scale` are the + public selector names. +- `Inputs(...)` is the preferred pipeable modifier. `ModelSpec(...; inputs=...)` + is also accepted for programmatic construction. +- `TimeStep(...)` is the canonical timestep configuration. +- `Environment(...)` owns provider/resolver/source configuration. Temporal + value windows belong to the consuming `Inputs(...)` selector. +- Object templates own reusable model applications and parameters, not plant + topology construction. They consume explicit object trees or MTGs adapted + through `objects_from_mtg`. +- The old multiscale and mapping-transform implementations have been removed. + +## Completion Evidence + +The requirement-by-requirement evidence and final verification commands are +recorded in `composite_model_completion_audit.md`. diff --git a/docs/src/dev/maespa_model_handoff.md b/docs/src/dev/maespa_model_handoff.md new file mode 100644 index 000000000..a4f27e888 --- /dev/null +++ b/docs/src/dev/maespa_model_handoff.md @@ -0,0 +1,161 @@ +# MAESPA-Style CompositeModel Example Handoff + +The executable acceptance example is `examples/maespa_model_example.jl`, with +focused coverage in `test/test-maespa-model-example.jl`. + +## CompositeModel Shape + +- One `:Scene` object owns canopy microclimate and model-scale fluxes. +- One shared `:Soil` object owns soil water state. +- Species A and B are reusable `CompositeModelTemplate`s mounted as independent + `ObjectInstance`s. +- Each plant instance contains one plant object, one internode object, and its + own leaf objects. +- Species parameters differ while the model application structure is shared. + +Leaf applications use the copied PlantBiophysics subsample models: + +- `Monteith` for `:energy_balance`; +- `Fvcb` for `:photosynthesis`; +- `Tuzet` for `:stomatal_conductance`. + +## Coupling + +The model energy-balance application controls iterative leaf and soil calls. +`Calls(...)` expresses execution ownership only: the scene model decides when +leaf and soil subprocesses run. + +```julia +ModelSpec(scene_model; name=:scene_eb) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :psi_soil => + One(kind=:soil, scale=:Soil, application=:soil_water, var=:psi_soil), + ) |> + Calls( + :energy_balance => + Many(kind=:plant, scale=:Leaf, process=:energy_balance), + :soil => + One(kind=:soil, scale=:Soil, application=:soil_water), + ) |> + TimeStep(Dates.Hour(1)) +``` + +Trial leaf calls use `run_call!(target)` and do not publish. The accepted +solution uses `run_call!(target; publish=true)`, so temporal outputs and +environment writes are emitted exactly once. + +Scene/soil values are wired declaratively with `Inputs(...)`, not by manually +writing another object's status. The soil model receives accepted scene fluxes +through live references: + +```julia +ModelSpec(SoilWater(...); name=:soil_water) |> + AppliesTo(One(kind=:soil, scale=:Soil)) |> + Inputs( + :transpiration => + One( + scale=:Scene, + within=SceneScope(), + application=:scene_eb, + var=:scene_transpiration, + ), + :infiltration => + One( + scale=:Scene, + within=SceneScope(), + application=:scene_eb, + var=:scene_infiltration, + ), + ) |> + TimeStep(Dates.Hour(1)) +``` + +This creates a parent-controlled feedback loop: the scene reads mapped +`psi_soil` when it starts its energy-balance solve, computes accepted scene +water fluxes, writes `scene_transpiration` and `scene_infiltration`, then calls +the soil model. The soil call sees those scene values through input carriers +and publishes the updated soil state. If the intended science is an explicit +lag rather than same-step parent control, use `PreviousTimeStep(:psi_soil)` on +the scene input. + +CompositeModel LAI receives live references to every leaf area: + +```julia +ModelSpec(LAIModel(ground_area); name=:lai_dynamic) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :leaf_areas => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + process=:leaf_state, + var=:leaf_area, + ), + ) |> + TimeStep(Dates.Day(1)) +``` + +The scene energy-balance model uses the same mapping mechanism for leaf-scale +values needed during the hard-call solve. It maps leaf area, leaf carbon, trial +leaf inputs (`Ra_SW_f`, `aPPFD`, `Ψₗ`), and accepted leaf fluxes (`Rn`, `λE`, +`H`, `A`) into scene-level vector inputs. The scene model then writes or reads +those vectors, while the referenced leaf statuses remain the single source of +truth. + +```julia +ModelSpec(scene_model; name=:scene_eb) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :leaf_areas => Many(kind=:plant, scale=:Leaf, within=SceneScope(), var=:leaf_area), + :leaf_carbon => Many(kind=:plant, scale=:Leaf, within=SceneScope(), var=:leaf_carbon), + :leaf_Ra_SW_f => Many(kind=:plant, scale=:Leaf, within=SceneScope(), var=:Ra_SW_f), + :leaf_aPPFD => Many(kind=:plant, scale=:Leaf, within=SceneScope(), var=:aPPFD), + :Ψₗ => Many(kind=:plant, scale=:Leaf, within=SceneScope(), var=:Ψₗ), + :leaf_rn => Many(kind=:plant, scale=:Leaf, within=SceneScope(), policy=HoldLast(), var=:Rn), + :leaf_lambda_e => Many(kind=:plant, scale=:Leaf, within=SceneScope(), policy=HoldLast(), var=:λE), + :leaf_h => Many(kind=:plant, scale=:Leaf, within=SceneScope(), policy=HoldLast(), var=:H), + :leaf_a => Many(kind=:plant, scale=:Leaf, within=SceneScope(), policy=HoldLast(), var=:A), + ) +``` + +`HoldLast()` is intentional for the leaf flux vectors: it asks the compiler for +live references to the current held status values, so the parent scene solve can +iterate hard-call trial states without materializing temporal streams. + +Allocation is plant-local because its leaf selector uses `within=Subtree()`: + +```julia +ModelSpec(allocation; name=:allocation) |> + AppliesTo(One(scale=:Plant)) |> + Inputs(:leaf_carbon => Many(scale=:Leaf, within=Subtree(), var=:leaf_carbon)) |> + TimeStep(Dates.Day(1)) +``` + +## Meteorology + +Input meteorology is above-canopy forcing. CompositeModel status stores the resulting +below-canopy microclimate: + +- `canopy_tair`; +- `canopy_vpd`; +- `canopy_rh`; +- `canopy_htot`; +- `canopy_gcanop`. + +`tvpdcanopcalc(...)` and `gbcanms(...)` implement the MAESPA-style canopy +temperature, humidity, and aerodynamic-conductance update. + +## Acceptance Checks + +The focused test verifies: + +- five leaves across two species and one shared soil object; +- instance membership and mounted application ids; +- model calls to all leaf energy-balance applications and the soil model; +- nested `Monteith -> Fvcb -> Tuzet` call bundles; +- live-reference LAI and plant-local allocation bindings; +- hourly energy balance and daily LAI/allocation schedules; +- exactly one accepted publication per manually called target and timestep; +- finite canopy microclimate, leaf energy, photosynthesis, soil feedback, and + species-specific allocation after a 25-hour run. diff --git a/docs/src/dev/public_api_refinement_completion_audit.md b/docs/src/dev/public_api_refinement_completion_audit.md new file mode 100644 index 000000000..8f8bfa1e2 --- /dev/null +++ b/docs/src/dev/public_api_refinement_completion_audit.md @@ -0,0 +1,43 @@ +# Public API Refinement Completion Audit + +This audit records the supported contract and the evidence used to stabilize it. +It complements the [decision record](public_api_refinement_decisions.md) and the +[public symbol inventory](../API/public_symbols.md). + +## Contract evidence + +| Requirement | Supported contract | Evidence | +|:--|:--|:--| +| Public boundary | Composition, model-author, diagnostic, and extension symbols are exported by default; compiler/cache representations live under `PlantSimEngine.Advanced`. | `test-model-api-stabilization.jl` checks the namespace boundary; Documenter's missing-doc check covers exported docstrings. | +| Application identity | Repeated process applications require explicit names. Inputs, calls, outputs, and `Updates(...; after=...)` use canonical application IDs. Singular process lookup is a deprecation bridge; `Many(process=...)` remains explicit discovery. | Stabilization, binding-inference, hard-call, output, and update tests cover repeated applications and actionable ambiguity errors. | +| Selector grammar | `Self()` is one object, `Subtree()` is that object plus descendants, `SelfPlant()` is the containing plant, and `SceneScope()` is the model. `One`, `OptionalOne`, and `Many` share the same criteria across targeting, coupling, lookup, and outputs. | Multi-plant selector tests, instance/template tests, lifecycle tests, and XPalm downstream tests. | +| Outputs | `outputs=:none` is the safe default; `:all` and selector-based `OutputRequest`s are explicit. Request names are unique, application identity is preserved, and removed-object history remains collectable. | Output-boundary, runtime-matrix, multirate, lifecycle-history, and allocation tests. | +| Execution ownership | `run!` starts a fresh simulation; `continue!` and `step!` advance its live handle without resetting time, streams, schedules, or environment position. | Split-run equivalence, multirate-boundary, environment-resume, and lifecycle-continuation tests. | +| Construction and initialization | `CompositeModel(models...; status=...)` lowers to ordinary objects and `ModelSpec`s. `explain_initialization` reports application, object, origin, defaults, expected/provided types, and remedies without running kernels. | Concise/explicit lowering equivalence and initialization report tests. | +| Diagnostics | Supported explanation functions accept `CompositeModel` directly and compiled views where useful; simulation overloads avoid field inspection. Results are structured vectors that can be filtered with ordinary Julia predicates. | Structured explanation assertions throughout the model test matrix and documentation examples. | +| Lifecycle | Registration, MTG growth, removal, reparenting, movement, and geometry updates are the supported mutation paths. Cycle/self-parent failures are atomic; structural and geometry invalidation remain targeted. | Stabilization, unified integration, environment, and lifecycle-output tests. | +| Model-author API | The kernel remains `run!(model, models, status, meteo, constants, extra)`. `runtime_model`, call-target accessors, traits, and lifecycle helpers are the supported context surface. | `test-model-contract.jl`, hard-call tests, growing-plant tutorial, and downstream model suites. | +| Compatibility | `tracked_outputs`, singular scenario `process=` references, output-request `process=`, and process-only overrides have targeted warnings and documented replacements, scheduled for removal in 0.15. Mapping runtimes are not restored. | Migration guide plus compatibility tests. | + +## Validation matrix + +The release gate is: + +1. complete PlantSimEngine package tests, including allocation gates and doctests; +2. a full Documenter build with missing-doc and executable-example checks; +3. full PlantBiophysics and XPalm downstream suites against this checkout; +4. benchmark smoke tests for native, multirate, PlantBiophysics, and XPalm paths; +5. `git diff --check` and searches for transitional spellings outside explicit + migration/history documentation. + +This matrix covers one/many objects, all selector multiplicities, soft inputs, +hard calls, duplicate writers, temporal policies, global/spatial environments, +templates, instances, overrides, lifecycle mutation, generic values, output +retention modes, fresh/continued execution, and homogeneous hot-loop allocation. + +## Deliberate compatibility boundary + +Compiled structs and cache controls are qualified advanced APIs and may evolve. +Direct mutation of `Object` or `CompositeModel` fields is unsupported. Historical +`ModelMapping`, executor, and status-vector runtimes are outside the compatibility +surface and must not be reintroduced. diff --git a/docs/src/dev/public_api_refinement_decisions.md b/docs/src/dev/public_api_refinement_decisions.md new file mode 100644 index 000000000..d27fc9b23 --- /dev/null +++ b/docs/src/dev/public_api_refinement_decisions.md @@ -0,0 +1,108 @@ +# Public API refinement decisions + +This decision record defines the target public contract for the CompositeModel/Object +API. The CompositeModel/Object compiler and runtime remain the only supported scenario +runtime. + +## Terminology and identity + +- An **object** is one runtime entity with a stable `ObjectId`. +- A **model** is one scientific implementation of a process. +- A **process** is model metadata and may have several applications. +- An **application** is one named, configured occurrence of a model in a model. +- User declarations that identify a producer, writer, update predecessor, call + target, or output stream use application identity. +- Process queries are discovery filters. They are not substitutes for an + application identifier when more than one application matches. +- Every application receives a deterministic identifier. An explicit + `ModelSpec(...; name=...)` is used verbatim. An unnamed application uses its + process name only when that identifier is unique; repeated unnamed + applications are rejected with instructions to name them. +- Mounted template applications are qualified as + `instance_name__application_name`. + +## Object selectors and scope + +The same `One`, `OptionalOne`, and `Many` selector values are accepted by +application targeting, inputs, calls, object queries, and output requests. + +Scope names have one meaning: + +- `Self()` selects only the current object. +- `Subtree()` selects the current object and all of its descendants. +- `SelfPlant()` selects the current object's plant root and its descendants. +- `Ancestor(...)` selects the matching ancestor's subtree. +- `SceneScope()` searches the whole model. +- `Scope(name)` searches the named object's subtree. +- `Relation(...)` selects objects with the requested topological relationship. + +Selectors that require a current object fail when used without a context. +Cross-object coupling is always visible in the declaration through `Subtree`, +`SelfPlant`, `Ancestor`, `Scope`, `SceneScope`, or `Relation`. + +## Outputs + +`run!` uses an explicit `outputs` keyword: + +```julia +run!(model; outputs=:none) +run!(model; outputs=:all) +run!(model; outputs=request) +run!(model; outputs=requests) +``` + +The default is `outputs=:none`. Temporal dependency streams required by the +runtime are still retained with bounded histories; they are not user-retained +outputs. + +`tracked_outputs` is deprecated. During the deprecation period, +`tracked_outputs=nothing` maps to `outputs=:all`, an empty request vector maps +to `outputs=:none`, and other values map to `outputs=value`. + +An `OutputRequest` contains an object selector, a variable, an optional +application identifier, a unique result name, and optional temporal resampling +policy. `OutputRequest(:Leaf, :x)` remains a convenience spelling that lowers +to `OutputRequest(Many(scale=:Leaf), :x)` during migration. + +## Execution and continuation + +`run!(model; steps=n, ...)` starts a fresh result timeline at step one while +mutating model status. It returns a live `Simulation` execution handle. + +`continue!(simulation; steps=n)` advances that simulation from its current +step, preserving retained streams, temporal dependency history, environment +position, and multirate clock phase. It returns the same simulation. + +`step!(simulation)` is equivalent to `continue!(simulation; steps=1)`. + +Calling `run!` on an already-mutated model intentionally creates a new result +timeline. Users who intend temporal continuation use `continue!`; the distinct +operation prevents an accidental step-index reset. + +Lifecycle mutations between calls to `continue!` are compiled before the next +timestep using the existing targeted invalidation contract. + +## Public namespaces + +The default namespace is organized around: + +- model composition and execution; +- model-author declarations and kernel helpers; +- supported structured explanations; +- documented environment extension interfaces. + +Compiled representation types, cache dirty flags, raw compiler stages, and +low-level invalidation helpers are qualified advanced/internal APIs unless a +documented external extension requires them. Removing an export does not make a +symbol inaccessible through `PlantSimEngine.Symbol`; it removes the accidental +promise that ordinary users should depend on it. + +## Compatibility policy + +- Removed legacy mapping/executor APIs are not restored. +- Current CompositeModel/Object spellings receive targeted deprecations only when they + have a clear replacement. +- New canonical spellings are implemented and tested before deprecated aliases + are removed. +- Benchmarks, examples, documentation, PlantBiophysics, and XPalm target the + canonical API rather than deprecated compatibility paths. diff --git a/docs/src/dev/release_notes_handoff.md b/docs/src/dev/release_notes_handoff.md new file mode 100644 index 000000000..cdfddf7c8 --- /dev/null +++ b/docs/src/dev/release_notes_handoff.md @@ -0,0 +1,483 @@ +# Release Notes Handoff + +This page is the persistent release-note source for the composite-model/object redesign +and cleanup branch. Keep it factual: mark what is implemented, what is removed, +and what is only planned. + +## Implemented Breaking Cleanup + +Source details live in `code_cleanup_audit.md`. + +- Removed `ModelList`, `ModelMapping`, `GraphSimulation`, `MultiScaleModel`, + and the separate mapping dependency/runtime stack. Use `CompositeModel`, `Object`, + and model applications. +- Removed direct and batch mapping `run!` methods. +- Removed string scale names. Use symbols, for example `:Leaf`. +- Removed mapping-specific type-promotion configuration. +- Removed `ModelMapping` completely; it is not retained as a qualified + compatibility API. +- Removed old multiscale output indexing helpers. Convert outputs explicitly + before indexing. +- Replaced mapping-specific same-scale rename sentinels with + `Inputs(:local => One(within=Self(), var=:source))`. +- Removed unused parallel-executor traits after deleting the executor runtime. +- Removed dead mapping-era wrappers and traits: `UninitializedVar`, + `RefVariable`, `TreeAlike`, and `StatusView`. +- Removed the unreleased `CompositeModelTemplate(...; mapping=...)` alias and dead + selector-to-mapping conversion helpers. +- Removed stale `PlantSimEngine.Examples` exports for the deleted + `ToyInternodeEmergence` example. +- Replaced many source-side validation `@assert`s with explicit errors. +- Added `Updates(:var; after=:application)` for ordered duplicate writers. +- Added `runtime_model(runtime)` as the sanctioned live-model accessor for + `RunContext` and `Simulation`; kernels no longer need to inspect + `extra.compiled.model`. +- Added `explain_initialization(model)` with structured `:supplied`, + `:generated`, `:producer_bound`, `:environment_bound`, and `:unresolved` + dispositions. +- Added `CompositeModel(model, models...; status=...)` as a thin one-object constructor + that lowers to the normal object and `ModelSpec` representation. +- Calendar-aligned windows remain unsupported. Temporal windows use + duration-based `Dates.Period` semantics. + +## Removed Unreleased Scenario Prototype + +An experimental scenario runtime was developed and replaced on this branch +before release. Its source, tests, examples, and documentation were removed +rather than retained as compatibility code. + +The removed API included `Domain`, `SimulationMapping`, `Route`, +`AllDomains`, and `HardDomains`, together with the domain scheduler, run loops, +route materialization, environment bridge, graph runner, and output publisher. +Because this API was never released, there is no compatibility layer or user +migration path for it. + +The reusable behavior now lives in the composite-model/object runtime: object selectors, +compiled `Inputs(...)`, manual `Calls(...)`, `Dates`-based scheduling, +environment backends, dynamic object lifecycle handling, and structured +explanations. + +Dynamic MTG growth now has one public high-level operation: `add_organ!`. +An MTG-backed `CompositeModel` retains the accessors and status initializer used during +initial adaptation. `add_organ!` reuses that policy for new nodes, merges +explicit initial values, attaches the resulting `Status`, registers the model +object, and invalidates runtime bindings. `register_object!` remains available +as the low-level registry operation. XPalm and PlantGeom were migrated away +from package-local wrappers that duplicated this lifecycle sequence. + +## Implemented MAESPA-Style Example Changes + +The current `examples/maespa_model_example.jl` is the main executable example +for multi-plant model coupling. + +- Uses copied PlantBiophysics subsample models: + `Monteith`, `Fvcb`, and `Tuzet`. +- Uses two plant instances with different parameters and shared scale names + such as `:Plant` and `:Leaf`. +- Uses a shared soil model. +- Uses `SceneEB` with `ModelSpec(...) |> Calls(...)` to manually run leaf + `:energy_balance` and soil `:soil_water` targets. +- Ports MAESPA-style canopy air temperature and VPD update through + `tvpdcanopcalc` and `gbcanms`. +- Treats input meteorology as above-canopy forcing and writes below-canopy + microclimate to model status fields: + `canopy_tair`, `canopy_vpd`, `canopy_rh`, `canopy_htot`, and + `canopy_gcanop`. +- Adds `LAIModel` and declares plant leaf-area materialization with + `ModelSpec(...) |> Inputs(...)`. +- Computes plant allocation daily from plant-local `leaf_carbon` vectors. +- Adds `run_call!` for manually executing compiled model call targets. +- Adds model-level `Input(...)` and `Call(...)` dependency defaults through + `dep(model)`, with scenario-level `Inputs(...)` and `Calls(...)` overriding + those defaults in `ModelSpec`. +- Adds initial registry-backed model selector resolution with + `resolve_object_ids` and `resolve_objects` for global, self-relative, + plant-relative, ancestor-relative, and named-scope object selections. +- Adds `explain_scopes(model)` for structured scope diagnostics. It reports + the model scope, object subtree scopes, named `Scope(...)` entries, and + scale/kind/species label groups with concrete object ids. +- Selector failures now include context, matched object ids, requested + criteria, available labels, and near-match suggestions. Misspelled labels + such as `scale=:Leef` therefore suggest `:Leaf` instead of returning only a + cardinality count. +- `Relation(...)` now supports `:self`, `:parent`, `:children`, `:ancestors`, + `:descendants`, and `:siblings` in `AppliesTo`, `Inputs`, and `Calls` + selectors. Relation results are compiled to concrete object ids and may be + constrained by an explicit scope. +- `ObjectAddress` explanations now preserve positional selector criteria such + as `Scale(:Leaf)` and `Relation(:parent)`. +- Adds the first compiled composite-model/object view with `Advanced.compile_composite_model`, + `Advanced.CompiledCompositeModel`, `Advanced.CompiledModelApplication`, `Advanced.CompiledModelInputBinding`, + `Advanced.CompiledModelCallBinding`, `explain_applications`, + `explain_bindings`, and `explain_calls`. +- The compiled model view resolves `AppliesTo(...)`, `Inputs(...)`, and + `Calls(...)` to object ids ahead of runtime, and reports temporal policy, + window, carrier hints, and callee application ids for agent-readable + diagnostics. +- Unscoped composite-model/object dependency selectors now infer scope from the consumer: + model consumers default to `SceneScope()`, while non-model consumers default + to `Self()`. Cross-scope shared dependencies, such as leaf models reading + soil state, should use `within=SceneScope()` explicitly. +- Adds status-backed compiled input carriers for the composite-model/object view: + scalar shared refs, homogeneous `RefVector`s, and `Advanced.ObjectRefVector` fallback + carriers. `input_carrier`, `input_value`, and `has_reference_carrier` expose + them for tests, diagnostics, and future runtime execution. +- Same-rate model inputs are now wired into consumer `Status` references once + during compilation. Scalar and `Many(...)` inputs remain live references, + missing bound input fields are compiler-generated, and repeated non-temporal + input materialization is allocation-free. +- Same-rate `Inputs(...)` carriers preserve arbitrary concrete value types. + Regression coverage passes a dual-like `BigFloat` wrapper through a typed + `RefVector`, model arithmetic, source mutation, and output publication + without conversion to `Float64`. +- Same-object variable renaming now uses normal `Inputs(...)` syntax instead of + `SameScale()`. Renamed inputs share the producer reference and contribute the + expected producer-to-consumer scheduling edge. +- `explain_bindings` now reports stable carrier kind and copy/reference + semantics, making reference-wired inputs and materialized temporal values + explicit for users and agents. +- Adds model binding cache helpers: + `Advanced.refresh_bindings!`, `Advanced.bindings_dirty`, `Advanced.compiled_bindings`, and + `Advanced.model_revision`. Object registration, removal, and reparenting invalidate + cached compiled bindings before the next refresh. +- Adds composite-model/object environment binding cache helpers: + `Advanced.refresh_environment_bindings!`, `Advanced.compile_environment_bindings`, + `Advanced.CompiledEnvironmentBinding`, `Advanced.CompiledEnvironmentBindings`, + `Advanced.environment_bindings_dirty`, `Advanced.compiled_environment_bindings`, + `Advanced.environment_revision`, and `explain_environment_bindings`. +- Adds `geometry`, `position`, and `bounds` accessors for model objects/statuses. + Environment binding refreshes call `update_index!(backend, entities)` before + binding objects to backend cells/layers, so spatial backends can precompute + model-wide lookup structures. +- Spatial environment binding now falls back to the nearest ancestor geometry + for objects without their own geometry. Binding explanations expose the + geometry provenance, and moving an ancestor refreshes only descendants that + inherit its geometry. +- Environment binding refresh can now update changed `meteo_inputs_` and + `meteo_outputs_` metadata without repeating spatial indexing or cell lookup + when the application/object/provider/geometry contract is otherwise + unchanged. +- `Environment(; sources=(CO2=:Ca,))` now remaps model-facing environment + variables to backend source variables. CompositeModel environment binding refresh + validates missing source variables for enumerable backends such as + `GlobalConstant`, and explanations expose both `required_inputs` and + `source_inputs`. +- `validate_meteo_inputs(model)` and + `validate_meteo_inputs(compiled_scene, meteo_or_backend)` now validate + composite-model/object environment contracts directly. Missing-variable diagnostics use + model application ids, and validation honors both scenario + `Environment(; sources=...)` remaps and model-author `meteo_hint` defaults. +- Object movement now invalidates environment bindings without rebuilding the + structural object/model binding cache. +- Adds public geometry lifecycle helpers: + `update_geometry!(model, object, geometry; invalidate_environment=true)` and + object-scoped `mark_environment_binding_dirty!(model, object)`. They + currently invalidate the model environment binding cache and leave room for + finer-grained dirty tracking later. +- Adds the first composite-model/object runtime with `run!(model; steps=...)`. + It materializes compiled `Inputs(...)` carriers, samples bound environment + inputs, and executes generic model kernels on object `Status` values. +- CompositeModel/object compiler now infers simple same-object value bindings from + `inputs_`/`outputs_` when one producer is unambiguous. `explain_bindings` + reports each binding origin, including `:model_default`, `:model_spec`, and + `:inferred_same_object`. +- Compiled input bindings now validate `Inputs(...)` `process=`/`application=` + filters when they are provided, and `explain_bindings` reports + `source_application_ids`, `process`, and `application`. +- `Advanced.compile_composite_model` now errors for required `inputs_(model)` variables that are + neither bound through `Inputs(...)`/inference nor present on the target object + `Status`. +- `Advanced.compile_composite_model` now prepares model-owned status schemas automatically: + model-targeted objects may omit `Status`, declared model/environment outputs + are inserted from trait defaults, and bound consumer inputs are generated + from `inputs_` defaults. External unbound inputs still require explicit + initialization. +- `Advanced.compile_composite_model` now rejects `Inputs(...)` entries whose receiving variable is + not declared by the model's `inputs_`, making binding typos explicit at + compile time. +- `Advanced.compile_composite_model` now validates status-backed non-temporal `Inputs(...)` + source availability, so bindings that select existing source objects but no + source `Status` reference fail at compile time instead of becoming no-ops. +- CompositeModel/object runtime now publishes model outputs to model-local temporal + streams and resolves temporal `Inputs(...)` with `HoldLast`, `Integrate`, + and `Aggregate` policies before consumer execution. +- CompositeModel temporal `Inputs(...)` now use producer `output_policy(...)` traits as + the default when the selector omits `policy=...` and resolves to a unique + source application. Explicit selector policies override the trait. +- CompositeModel applications now infer model-author default environment source remaps + from `meteo_hint(...).bindings` when the scenario does not provide explicit + meteo bindings. Scenario `Environment(; sources=...)` remains the override. +- CompositeModel/object runtime now scatters values declared by `meteo_outputs_(model)` + back to the bound environment backend after each model call, using the + existing `scatter_environment_outputs!` backend protocol. +- CompositeModel/object root applications now honor `TimeStep(...)` values backed by + `Dates.Period` scheduling. `explain_schedule` reports normalized clocks and + whether an application is root-scheduled or manual-call-only. +- CompositeModel/object root applications now also honor `timespec(...)` model traits + when no explicit `TimeStep(...)` is provided. Scenario-level `TimeStep(...)` + remains the override. +- CompositeModel/object root applications now validate `timestep_hint(...)` required + bounds for clocks derived from the model base step. Hints remain + compatibility constraints, not scheduling overrides. +- CompositeModel/object execution now uses a stable topological application order + compiled from `Inputs(...)` producer edges and `Updates(...)` ordering. + Dependencies on manual-call-only applications are redirected to their parent + caller, same-timestep cycles fail during compilation, and + `explain_schedule` reports `execution_index`. +- `Advanced.CompiledCompositeModel` now pre-indexes input and call bindings by application and + object id. Runtime input materialization and hard-call lookup no longer scan + all model bindings for every object/model invocation. +- `Advanced.CompiledCompositeModel` now pre-indexes applications by application id, removing + application scans from hard-call target resolution and dictionary rebuilding + from ordered execution setup. +- `Advanced.CompiledEnvironmentBindings` now pre-indexes environment bindings by + application and object id, removing the model-wide binding scan from + environment sampling and output scattering. +- Adds `RunContext` and `CallTarget`; composite-model/object models can use + `run_call!(extra, :name)` plus `call_targets(extra, :name)` for fine-grained manual `Calls(...)` + execution. +- Applications selected by `Calls(...)` are skipped by the root + `run!(model)` loop and execute only through explicit `run_call!`, preserving + parent-controlled hard-call execution. +- Adds composite-model/object duplicate-writer validation in `Advanced.compile_composite_model`. A variable + may have only one canonical writer per object unless later writers declare + `Updates(:var; after=...)`, where `after` can match a previous application + id/name or process. +- Adds `explain_writers(compiled)` to report object-variable writer groups, + duplicate writers, and the `Updates(...)` declarations that validate ordered + updates. +- Adds the first reusable object-template path with `CompositeModelTemplate` and + `ObjectInstance`. Templates bundle reusable `ModelSpec`s and default + `kind`/`species` labels; instances mount them inside a named object subtree. +- `CompositeModel(...)` accepts mounted instances whose roots are either owned objects + or references to separately supplied model objects. +- Template applications are scoped to their instance and receive stable + instance-prefixed application ids. Unmodified instances share the template's + model objects, while instance overrides can replace one application by name + or process when the replacement implements the same process. +- Adds `Override(...)` and `ObjectInstance(...; object_overrides=...)` for + exceptional organs. Overrides are resolved during compilation to concrete + object ids without splitting the logical application or changing its + dependency bindings. +- Template models, template parameter metadata, and replacement models are + retained by reference. The runtime does not copy models or mutate fields to + apply parameter overrides. +- Override validation requires the same process and declared status/environment + variable names. Application explanations report model storage, dispatch + mode, overridden object ids, and replacement model types. +- Adds `explain_instances(model)` and instance membership in + `explain_objects(model)`. Instance rows expose roots, current object + membership, mounted applications, overrides, template labels, and + reference-based parameter ownership. +- Objects created below a mounted instance inherit missing template `kind` and + `species` labels. Membership explanations use the current topology rather + than a copied instance object list. +- CompositeModel hard calls now accept explicit local meteorology: + `run_call!(target; meteo=local_meteo, publish=false)`. This lets iterative + parent models pass trial microclimate to manually called child models without + resampling the model environment. +- CompositeModel hard calls now default to `publish=false`. Trial calls mutate target + status without publishing temporal samples or environment writes; accepted + states must use `run_call!(target; publish=true)`. Iterative-call tests verify + that several trials followed by one accepted call publish exactly once. +- `explain_calls(compiled)` now exposes the manual-call publication contract + through `publication_policy`, `default_publish`, and `accepted_publish` + fields. +- `ModelSpec` now keeps provenance for `Inputs(...)` and `Calls(...)`. + Declarations coming from `dep(model)` are `:model_default`, scenario-level + declarations and overrides are `:model_spec`, and structured explanations + expose these origins for release-note and migration diagnostics. +- Compiled `OptionalOne(...)` inputs and calls now accept zero matches. + Optional inputs keep their declared model default, optional calls return an + empty target collection, and both remain visible in structured explanations. +- CompositeModel runtime now builds a process-keyed `models` bundle from compiled + `Calls(...)` edges before invoking a model kernel. Existing hard-dependency + kernels such as `Monteith` and `Fvcb` can therefore keep using + `models.photosynthesis` and `models.stomatal_conductance`. +- CompositeModel duplicate-writer validation now ignores manual-call-only applications + when validating canonical root writers, so hard-dependency children are not + treated as independent root writers for variables they update inside a parent + call stack. +- Adds `build_maespa_scene(...)` and `run_maespa_example(...)`. + This unified composite-model/object MAESPA path uses `CompositeModelTemplate`, + `ObjectInstance`, `AppliesTo`, `Inputs`, `Calls`, and + `TimeStep(Dates.Period)` with two plant species, one shared soil object, + model LAI, and model energy balance. +- `test/test-maespa-model-example.jl` verifies the unified composite-model/object + MAESPA path. +- `run!(model)` now returns a `Simulation` wrapper containing the mutated + model, compiled object bindings, compiled environment bindings, and + model-local temporal output streams. +- Adds model output inspection helpers: + `outputs(sim::Simulation)`, `collect_outputs(sim)`, and + `explain_outputs(sim)`. These expose object ids, variables, publishing + application ids, sample counts, time bounds, and value types. +- `run!(model; tracked_outputs=...)` now accepts `OutputRequest` for + composite-model/object runs. Requested outputs are collected from retained typed model + streams after the run, can be read with `collect_outputs(sim)` or + `collect_outputs(sim, :request_name)`, support the standard temporal + policies and `Dates.Period` export clocks, and respect dynamic object + lifetimes by exporting each object only across its own sample interval. This + now prunes retained streams at publisher level: `tracked_outputs=nothing` + keeps all streams, explicit requests keep requested application/variable + streams plus streams required by temporal `Inputs(...)`, and + `tracked_outputs=OutputRequest[]` keeps no streams unless temporal + dependencies require them. Dependency-only streams now have bounded + policy-specific histories: latest-only for `HoldLast`, the required window + for `Integrate`/`Aggregate`, and sufficient recent source samples for + `Interpolate`/`PreviousTimeStep`. Requested and default retain-all streams + still preserve complete histories, and export remains post-run rather than + fully online. +- Adds `explain_output_retention(sim)` for structured diagnostics of retained + model output streams, their reasons, and the compiled retention horizon for + dependency-only streams. +- CompositeModel temporal streams are now keyed by application id, object id, and + variable, so two applications can publish the same variable on the same + object without overwriting each other's stream samples. +- CompositeModel output-export tests now cover requested-output `DataFrame` + materialization, canonical publisher inference without `process=...`, + rejection when only stream-only publishers exist, and ambiguity when an + explicit process matches both a stream-only and a canonical publisher. +- `OutputRequest(...)` now accepts `application=...` for composite-model/object runs. + This disambiguates repeated applications of the same process and permits + explicit export of a named `:stream_only` publisher. +- CompositeModel temporal streams now retain a concrete value type per + application/object/output stream. Type changes fail explicitly, while + generic values such as `BigFloat` remain typed through publication, + interpolation, and integration. +- CompositeModel temporal `Inputs(...)` now implement the complete `Interpolate(...)` + policy used by the existing multirate runtime: linear interpolation when + samples bracket the requested time, online linear extrapolation from the + last two samples, and configurable hold behavior. Interpolation modes are + validated during model compilation, and arithmetic preserves generic value + types such as `BigFloat` instead of coercing model values to `Float64`. +- CompositeModel `Inputs(...)` accepts + `PreviousTimeStep(:input) => One(...)` or `Many(...)` for explicit lagged + dependencies. These bindings read the previous model timestep, use the + consumer status initialization before history exists, and are excluded from + same-timestep dependency edges so feedback loops can be compiled. +- CompositeModel `OutputRouting(; var=:stream_only)` is honored by canonical writer + validation and same-object input inference. Stream-only outputs are excluded + from canonical ownership, but remain available in output streams and explicit + `Inputs(..., application=:name)` bindings. +- CompositeModel execution now refreshes dirty structural bindings between timesteps. + Objects created, removed, or reparented by a model update application target + sets, input carriers, call targets, writer validation, and scheduling before + the next timestep. +- Geometry-only changes refresh environment bindings at the next timestep + without rebuilding structural bindings. `Simulation` returns the final + compiled structural and environment state, including changes made during the + last timestep. +- Geometry-only environment invalidation is now object-scoped. Moving or + explicitly marking one organ dirty preserves unaffected compiled environment + bindings and rebinds only model applications targeting that object; + structural model changes still trigger a full rebuild. +- Added runtime lifecycle coverage for organ creation, pruning, plant-local + `RefVector` refresh, historical output retention for removed objects, and + movement between mock microclimate cells. +- `Advanced.CompiledCompositeModel` now precompiles one process-keyed model bundle per + application/object target. Generic hard-dependency kernels receive this + cached bundle through the existing `models` argument, avoiding recursive + `Calls(...)` traversal and temporary collection allocation in the timestep + hot loop. +- Adds `explain_model_bundles(compiled)` for structured inspection of the + process names and model types passed to each application/object kernel. +- CompositeModel root execution now uses compiled homogeneous target batches. Models, + statuses, model bundles, input bindings, and environment bindings are + prebound, so dynamic dispatch happens once per batch instead of once per + object. Heterogeneous object overrides split into ordered concrete batches. +- Adds `explain_execution_plan(scene_or_simulation)` and a zero-allocation + warmed 128-leaf inner-loop regression gate. +- Manual `Calls(...)` handles now use the public + vector-like `run_call!(extra, name)` execute-all API, with + `call_targets(extra, name)` followed by `run_call!(target)` for fine-grained control. +- Removed the unreleased intermediate authoring and runtime subsystem after + composite-model/object feature parity was established. +- Adds `objects_from_mtg(root; ...)` and `CompositeModel(mtg; ...)` so existing MTG + topology can be adapted once into the unified registry while preserving + node-derived identity, parent relations, labels, geometry, and existing + status objects. +- CompositeModel applications now sample global tabular meteorology at their compiled + `Dates.Period` clock. PlantMeteo reducers and windows from `meteo_hint` are + honored; `Environment(; sources=...)` overrides the source while preserving + the reducer. Prepared samplers are shared, and one sampled row is cached per + application/timestep for all selected objects. + +## Compatibility Boundary + +The composite-model/object runtime and its MAESPA acceptance path are implemented. +Historical mapping APIs and the unreleased intermediate prototype were +removed. The design, implementation history, and completion evidence are +documented in: + +- `composite_model_design.md` +- `composite_model_implementation_plan.md` +- `composite_model_completion_audit.md` + +The completed public migration is: + +- replace historical tutorials with native composite-model/object tutorials where + long-term coverage is still valuable; +- model mappings should be described as model applications: + `ModelSpec(model; name=...) |> AppliesTo(...) |> Inputs(...) |> Calls(...)`; +- `MultiScaleModel(...)` -> `Inputs(...)`. +- `dep(model)` remains the model-level trait for default dependency intent: + defaults can become `Input(...)` value bindings or `Call(...)` manual model + calls, and scenario-level `ModelSpec` configuration overrides them. +- model target scales -> `AppliesTo(...)` object selectors. +- `InputBindings(...)` -> source, policy, and window information on + `Inputs(...)`. +- `MeteoBindings(...)` and `MeteoWindow(...)` -> automatic environment + binding plus `Environment(...)` provider/source overrides. +- `OutputRouting(...)` -> model-application output policy. +- `ScopeModel(...)` -> `AppliesTo(...)` plus selector scopes. +- `PreviousTimeStep(...)` remains supported as a temporal/cycle-breaking + marker in the unified object-address graph. +- explicit per-model meteo wiring -> automatic environment resolver plus + cached environment bindings. + +Historical mapping examples, tests, and runtime files were removed after the +composite-model/object acceptance path reached feature parity. Migration information is +kept in this release-note handoff and the user-facing migration guide. + +## Migration Documentation Added + +- Added `docs/src/migration_composite_model.md` as the user-facing migration guide + from historical mappings to the composite-model/object API. +- Updated documentation navigation, home-page guidance, multiscale warnings, + and the canonical repository agent skill to direct new + scenarios toward `CompositeModel`, `Object`, `AppliesTo`, `Inputs`, `Calls`, + `Updates`, `TimeStep`, and `Environment`. +- Replaced the documentation home-page quickstart with executable + composite-model/object examples. The page now introduces `CompositeModel`, `Object`, + `ModelSpec`, `AppliesTo`, `Inputs`, `TimeStep`, inferred same-object + bindings, multi-object `Many(...)` inputs, and manual `Calls(...)` syntax + before linking to the migration guide. +- Replaced the repository README examples with composite-model/object-first examples. + The README now introduces `CompositeModel`, `Object`, model applications, + multi-object `Inputs(...)`, and `Calls(...)`. +- Added a native composite-model/object quickstart page to the main documentation + navigation. It provides docs-tested examples for one-object model chaining, + inferred bindings, requested output retention, multi-object `Inputs(...)`, + reference carrier explanations, and manual `Calls(...)` syntax. +- Rewrote the model execution page as the current composite-model/object execution + guide. It now covers compilation, reference carriers, temporal `Inputs(...)`, + manual `Calls(...)`, `Updates(...)`, `TimeStep(...)`, environment binding, + retained outputs, lifecycle invalidation, and migration translations for + historical mapping constructs. +- Rewrote the detailed first simulation tutorial to use the composite-model/object API. + It now introduces `CompositeModel`, `Object`, `ModelSpec`, `AppliesTo`, `TimeStep`, + compiled applications, inferred same-object bindings, model outputs, and a + migration note for historical examples. +- Rewrote the quick examples page to use native composite-model/object snippets for + Beer light interception, degree-days/LAI/light coupling, biomass growth, and + retained `OutputRequest` exports. Historical mapping usage is confined to + migration records. +- Rewrote the standard model coupling, model switching, and coupling more + complex models tutorials around the composite-model/object API. These pages now show + inferred same-object value bindings, switching one `ModelSpec` application, + execution-plan explanations, and `Calls(...)` manual-call wiring. +- Removed legacy mapping transforms and their runtime implementations: + `MultiScaleModel`, `SameScale`, `TimeStepModel`, `InputBindings`, + `MeteoBindings`, `MeteoWindow`, and `ScopeModel`. +- Added a curated unified composite-model/object map to the public API page. diff --git a/docs/src/developers.md b/docs/src/developers.md index 0e6981384..3df614d7f 100644 --- a/docs/src/developers.md +++ b/docs/src/developers.md @@ -36,8 +36,9 @@ Run the standard test suite from the repository root: julia --project=test test/runtests.jl ``` -Some tests exercise threaded execution, so it is worth running them with more -than one Julia thread when validating parallel behavior. +The current public runtime is sequential. Running with multiple Julia threads +does not enable a parallel Composite model executor; parallel execution remains roadmap +work and requires dedicated correctness tests before it becomes public. ### Documentation @@ -71,6 +72,58 @@ If a change affects public APIs or execution behavior, check both `CI` and `Integration` before merging. Benchmark results are useful for regressions, but should be interpreted alongside the test results. +## Graph Viewer Frontend + +The static viewer and HTTP editor share the React application under +`frontend/`. PlantSimEngine releases include the production bundle in +`frontend/dist`, because Julia package installations do not run Node or Vite. +The content hash in asset filenames is intentional: it prevents browsers and +documentation hosts from reusing stale JavaScript after a release. + +Install the frontend development dependencies from the repository root: + +```sh +cd frontend +npm ci +``` + +Run the fast checks while developing: + +```sh +npm run typecheck +npm test +``` + +Build the production assets after changing TypeScript, CSS, or frontend +dependencies: + +```sh +npm run build +``` + +Commit the resulting `frontend/dist` changes together with the source changes. +Do not commit `frontend/node_modules`, Playwright reports, screenshots, videos, +or local test output. + +The end-to-end suite starts a real Julia `edit_graph` session and controls it +with Chromium: + +```sh +npx playwright install chromium +npm run test:e2e +``` + +Use `npm run test:e2e:ui` for a headed local debugging session. The tests use +stable `data-testid` attributes for commands and confirm mutations through the +Julia `/state` endpoint. Avoid assertions against generated CSS classes or +implementing PlantSimEngine selector semantics in TypeScript. + +Core graph DTO and edit tests live in `test/test-model-graph-view.jl`. +HTTP-extension tests live in `test/test-model-graph-editor-extension.jl`. +When changing the graph schema, update those Julia tests, frontend types, unit +tests, Playwright scenarios, and the committed production bundle in the same +change. + ## Documentation impact Changes in PlantSimEngine often require documentation updates beyond the page you @@ -95,16 +148,6 @@ were editing. ## Implementation notes -### Generated models from status vectors - -Some multiscale helpers turn status vectors into internal runtime models so that -they can be used in mapping-based simulations. The implementation is kept -deliberately data-driven to avoid top-level `eval()` and world-age issues. - -The relevant code lives in `src/mtg/mapping/model_generation_from_status_vectors.jl`. -If you touch that area, preserve the ability to generate the mapping and build a -`GraphSimulation` within the same function scope. - ### Coverage gaps to keep in mind Not every combination of weather structure, status shape, mapping layout, and diff --git a/docs/src/guides/coupling.md b/docs/src/guides/coupling.md new file mode 100644 index 000000000..fc189fbab --- /dev/null +++ b/docs/src/guides/coupling.md @@ -0,0 +1,66 @@ +# Coupling Models + +Use `Inputs` when a model reads a value produced by another application. A +unique same-object producer is inferred; cross-object sources should use an +explicit `One`, `OptionalOne`, or `Many` selector. Inspect the resolved +references with `explain_bindings`. + +Use `Calls` only when a parent algorithm owns child execution or iteration. +Use `run_call!(extra, :name)` to execute every resolved target. For selective +or iterative execution, retrieve the vector-like collection with +`call_targets(extra, :name)` and execute individual targets. Trial calls use +`publish=false`; accepted state is published once. +Nested calls inherit publication suppression, so a descendant cannot publish +inside an unpublished ancestor trial. `explain_calls` and `explain_schedule` +show call-only targets and ordering. + +`explain_initialization(model)` classifies values as supplied, generated, +producer-bound, environment-bound, or unresolved before execution. + +## Value coupling + +A consumer on the same object needs no scenario syntax when exactly one +canonical producer exists. Make cross-object intent explicit: + +```julia +ModelSpec(PlantBalance(); name=:balance) |> + AppliesTo(Many(scale=:Plant)) |> + Inputs( + :assimilation => Many( + scale=:Leaf, + within=Subtree(), + application=:photosynthesis, + var=:carbon, + ), + :soil_water => One( + scale=:Soil, + within=SceneScope(), + application=:soil, + var=:water, + ), + ) +``` + +`One` is a contract: zero or multiple matches are errors. Use `OptionalOne` +only when absence has a scientific meaning, and `Many` when aggregation is +part of the consumer model. `within=Subtree()` searches descendants of the +current target; `within=SelfPlant()` anchors repeated plant instances; and +`SceneScope()` is deliberately global. + +## Manual calls + +```julia +ModelSpec(Optimizer(); name=:optimizer) |> + AppliesTo(Many(scale=:Plant)) |> + Calls(:leaf_energy => Many(scale=:Leaf, within=Subtree())) +``` + +Inside `Optimizer`, iterate over `call_targets(extra, :leaf_energy)`. Run +candidate states with `run_call!(target; publish=false)` and the accepted state +with `publish=true`. A call-only target is excluded from root scheduling, and +an unpublished outer call suppresses publication by every nested descendant. + +After compilation, inspect `explain_bindings(compiled)` for source identity +and carrier type, `explain_calls(compiled)` for call-only targets, and +`explain_schedule(compiled)` for root execution order. These rows are the +supported diagnostic surface; compiled fields are internal. diff --git a/docs/src/guides/data/environment_inputs.md b/docs/src/guides/data/environment_inputs.md new file mode 100644 index 000000000..5622cf55c --- /dev/null +++ b/docs/src/guides/data/environment_inputs.md @@ -0,0 +1,11 @@ +# Weather And Environment Inputs + +An environment may be a constant named tuple, one tabular row, or regular +multi-row weather. Every row in a timed sequence needs a positive fixed +`duration`; inconsistent base durations and application substeps are rejected. + +Use `Environment(sources=...)` to map model-facing meteorological names to +provider columns. Values retain compatible user numeric types. Spatial +providers implement the same model-facing contract and refresh bindings after +object movement or geometry changes. + diff --git a/docs/src/guides/data/forcing_observations.md b/docs/src/guides/data/forcing_observations.md new file mode 100644 index 000000000..0b1448d5b --- /dev/null +++ b/docs/src/guides/data/forcing_observations.md @@ -0,0 +1,10 @@ +# Forcing Observed Variables + +Supply a constant observed value in object status when it does not vary. For a +time-varying observation, use a small environment-driven source model that +publishes the canonical variable; downstream applications remain unchanged. + +Replace a process for an entire template instance through instance overrides, +or use `Override` for one exceptional object. The replacement must implement +the same process and input/output contract. + diff --git a/docs/src/guides/data/numerical_reliability.md b/docs/src/guides/data/numerical_reliability.md new file mode 100644 index 000000000..6d5a6aec6 --- /dev/null +++ b/docs/src/guides/data/numerical_reliability.md @@ -0,0 +1,11 @@ +# Numerical Reliability + +Use exact assertions for deliberately exact integer/rational scenarios and +`isapprox` for floating-point scientific results. Splitting a computation +across objects may change reduction order without changing the model. + +PlantSimEngine preserves compatible numeric types through parameters, status, +carriers, meteorology, and streams. Avoid forced `Float64` conversion. For long +or ill-conditioned sums, use pairwise or compensated accumulation inside the +scientific model and test its error tolerance explicitly. + diff --git a/docs/src/guides/data/outputs_plotting.md b/docs/src/guides/data/outputs_plotting.md new file mode 100644 index 000000000..47fd5cfc1 --- /dev/null +++ b/docs/src/guides/data/outputs_plotting.md @@ -0,0 +1,56 @@ +# Collecting And Plotting Outputs + +Run a model to obtain `Simulation`, then call `collect_outputs(sim)` for +ordinary analysis. Rows identify application, object, variable, timestep/time, +and value, so repeated processes cannot overwrite one another. Convert the rows +to a `DataFrame`, filter by application/object/variable, group, and plot. + +Runs default to `outputs=:none`. Use `outputs=:all` only when complete stream +history is intentional; selected requests are the memory-safe choice for large +composite models. Raw rows have the stable columns `timestep`, `time`, `application_id`, +`object_id`, `variable`, and `value`. Requested/resampled rows additionally +identify `scale` and `process`. A temporal request emits `missing` when its +policy cannot produce a value for a scheduled output time. +`time` is expressed in model base-step coordinates; application clock metadata +is reported by `explain_schedule(simulation)`. Values retain their concrete +types, so unit-bearing model outputs remain unit-bearing in collected rows. + +`OutputRequest` controls requested retention or resampling. Dependency streams +may also be retained for runtime correctness. Use +`explain_output_retention(sim)` to see why each stream exists. Removed objects +retain accepted historical rows. + +```@example collect-output +using Dates +using DataFrames +using PlantSimEngine + +PlantSimEngine.@process "docs_output_counter" verbose = false +struct DocsOutputCounter <: AbstractDocs_Output_CounterModel end +PlantSimEngine.inputs_(::DocsOutputCounter) = NamedTuple() +PlantSimEngine.outputs_(::DocsOutputCounter) = (value=0,) +PlantSimEngine.run!(::DocsOutputCounter, models, status, meteo, constants, extra) = + (status.value += 1) + +model = CompositeModel(DocsOutputCounter(); environment=(duration=Hour(1),)) +simulation = run!( + model; + steps=3, + outputs=OutputRequest( + One(scale=:Scene), + :value; + name=:counter, + application=:docs_output_counter, + ), +) +rows = collect_outputs(simulation, :counter; sink=nothing) +table = DataFrame(rows) +@assert table.value == [1, 2, 3] +table +``` + +For plotting, filter the table first and map `time` to the horizontal axis and +`value` to the vertical axis. Group by `application_id` and `object_id` before +drawing lines; grouping by variable alone can accidentally connect different +objects. CairoMakie and other plotting packages consume the resulting columns +without any PlantSimEngine-specific adapter. diff --git a/docs/src/guides/graph_visualizer_editor.md b/docs/src/guides/graph_visualizer_editor.md new file mode 100644 index 000000000..26ca198ec --- /dev/null +++ b/docs/src/guides/graph_visualizer_editor.md @@ -0,0 +1,182 @@ +```@meta +CurrentModule = PlantSimEngine +``` + +# Visualize And Edit A CompositeModel + +The CompositeModel graph shows how model applications, objects, and compiled value +bindings fit together before a simulation runs. Use the static visualizer when +you want an inspectable HTML artifact, and the interactive editor when you want +browser actions to update a Julia [`CompositeModel`](@ref). + +## A Small CompositeModel + +This example applies three toy models to one plant object. The compiler infers +the same-object `TT_cu` and `LAI` bindings from the declared input and output +names. + +```@example graph_viewer +using PlantSimEngine +using PlantSimEngine.Examples + +model = CompositeModel( + ToyDegreeDaysCumulModel(), + ToyLAIModel(), + Beer(0.6); + status=(TT=12.0,), + id=:plant, + scale=:Plant, + kind=:plant, +) + +view = model_graph_view(model) +view.metadata +``` + +The documentation build writes that graph as a self-contained HTML page and +embeds it below. + +```@raw html + + +``` + +The default **Applications** projection groups all concrete executions of one +application into one card. Use **Objects** to inspect topology and **Executions** +to inspect concrete `(application, object)` pairs. Search, diagnostics, +initialization, selectors, parameters, and resolved edge details remain +available in the static viewer. The topology projection includes model and +instance containers; selecting an instance or object subtree scopes the +application and execution projections until the filter is cleared. + +## Write A Static Viewer + +The static visualizer is part of PlantSimEngine core and does not load a web +server: + +```julia +path = write_model_graph_view("model-graph.html", model) +``` + +The output bundles the graph payload, JavaScript, and CSS in one HTML file. It +can be opened locally or embedded in Documenter documentation. A downstream +package can generate the file from `docs/make.jl` and place it under +`docs/src/assets`: + +```julia +mkpath(joinpath(@__DIR__, "src", "assets")) +write_model_graph_view( + joinpath(@__DIR__, "src", "assets", "default_scene.html"), + default_scene(), +) +``` + +Then embed it from a Markdown page with an HTML `iframe`. The graph is +read-only, but its projection controls, search, inspector, and diagnostics are +interactive in the browser. + +## Start The Editor + +The mutable editor is an optional package extension activated by HTTP.jl. Add +HTTP once to the environment that will launch the editor: + +```julia +using Pkg +Pkg.add("HTTP") +``` + +Then start a session: + +```julia +using PlantSimEngine +using HTTP + +session = edit_graph(model) +``` + +The default browser opens automatically. The returned session also prints its +URL and shutdown command. Julia remains authoritative: browser edits are sent +as semantic commands, applied transactionally to a candidate CompositeModel, compiled, +and returned as a fresh graph state. + +Inspect the current result or stop the server with: + +```julia +edited_scene = current_model(session) +close(session) +``` + +Call `edit_graph()` without a CompositeModel to start from an empty scenario. Use +`open_browser=false` on remote machines or when a test controls the browser. + +## What Can Be Edited + +The editor supports: + +- model objects, metadata, status initialization, and parent topology; +- model applications, constructor parameters, target selectors, and cadence; +- explicit value bindings, hard calls, output routing, and update ordering; +- shared template applications plus instance-level and object-level overrides; +- dependency cycles through an explicit `PreviousTimeStep` break action; +- undo, redo, temporary recovery autosaves, and readable Julia CompositeModel scripts. + +Application target and binding dialogs can ask Julia to preview the concrete +objects selected by a declaration. This is important for `Many`, relative +scopes such as `SelfPlant`, and composite models containing several plant instances. + +## Models From Other Packages + +The model browser reflects concrete `AbstractModel` subtypes currently loaded +in Julia. There is no separate registration API. Loading a model package before +starting the editor makes its models available automatically: + +```julia +using PlantSimEngine +using PlantBiophysics +using HTTP + +session = edit_graph(model) +``` + +The `+` buttons next to ports use exact declared variable names only. For an +input named `LAI`, the editor lists loaded models whose `outputs_` contains +`LAI`. For an output named `LAI`, it lists models whose `inputs_` contains +`LAI`, as well as compatible applications already present in the CompositeModel. This is +a composition aid, not a scientific compatibility inference. + +When the CompositeModel is saved as Julia code, required package imports are emitted for +the model types used by the CompositeModel. + +## Invalid And Cyclic Composite Models + +Simulation compilation remains strict, but graph compilation preserves as much +structure as possible and attaches diagnostics. This lets the editor display +incomplete selectors, missing initialization, ambiguous writers, and cycles. + +Cycle edges are shown in red. The break workflow asks which consumer input +should read its previous accepted timestep value and asks for initial values +when the affected target objects do not already provide one. The action changes +the application input policy for every target selected by that application. + +!!! warning + `PreviousTimeStep` changes model semantics. It disconnects the selected + input from current-step producers during one run step. Use it only when that + lag and its initialization value are scientifically intentional. + +## Saving And Recovery + +The **Save** action writes readable Julia code whose final binding is +`model = CompositeModel(...)`. Once a path is selected, every successful edit rewrites +that file. The editor also keeps a temporary recovery file and lists recent +CompositeModel scripts in **Open**. + +Generated code is best effort for arbitrary Julia values and external runtime +resources. Review the code and keep important scenario scripts under Git. diff --git a/docs/src/guides/modelers/port_existing_model.md b/docs/src/guides/modelers/port_existing_model.md new file mode 100644 index 000000000..0da6897e7 --- /dev/null +++ b/docs/src/guides/modelers/port_existing_model.md @@ -0,0 +1,40 @@ +# Port An Existing Model + +Start with a one-step scientific function and separate four concerns: immutable +parameters, object state, environment forcing, and produced values. Keep the +arithmetic generic; a model should not convert compatible values to `Float64`. + +```@example port-existing-model +using Dates +using PlantSimEngine + +PlantSimEngine.@process "docs_lai_growth" verbose = false + +struct DocsLAIGrowth{T} <: AbstractDocs_Lai_GrowthModel + rate::T +end +PlantSimEngine.inputs_(::DocsLAIGrowth) = (lai=0.0,) +PlantSimEngine.outputs_(::DocsLAIGrowth) = (lai_next=0.0,) +PlantSimEngine.meteo_inputs_(::DocsLAIGrowth) = (T=0.0,) +function PlantSimEngine.run!(m::DocsLAIGrowth, models, status, meteo, constants, extra) + status.lai_next = status.lai + m.rate * meteo.T +end + +model = CompositeModel( + DocsLAIGrowth(0.02); + status=(lai=1.0,), + environment=(T=10.0, duration=Day(1)), +) +simulation = run!(model) +@assert only(model_objects(model)).status.lai_next == 1.2 +``` + +Test the scientific function first, then the kernel directly with a `Status`, +and finally the same model through a model. These three levels separate a +scientific error from a model-contract error and a scenario-binding error. +`explain_initialization(model)` should show `lai` as supplied, `T` as +environment-bound, and `lai_next` as generated. + +The concise constructor lowers to the ordinary CompositeModel compiler; it is not a +separate runtime. Move to explicit `Object` and `ModelSpec` construction only +when the scenario needs multiple objects, selectors, or named applications. diff --git a/docs/src/guides/modelers/stateful_models.md b/docs/src/guides/modelers/stateful_models.md new file mode 100644 index 000000000..8ee821b06 --- /dev/null +++ b/docs/src/guides/modelers/stateful_models.md @@ -0,0 +1,11 @@ +# State, History, And Repeated Updates + +Object `Status` is current state, not timestep storage. Use +`PreviousTimeStep(:x)` for a one-step lag, or keep a model-owned ring buffer +when the algorithm requires deeper history. Accepted output streams provide +simulation history. + +Several writers to one canonical variable are rejected unless the application +declares `Updates`. Iterative trial execution belongs to `Calls`; use +`publish=false` until a state is accepted. + diff --git a/docs/src/guides/multiscale/concepts.md b/docs/src/guides/multiscale/concepts.md new file mode 100644 index 000000000..a59456c14 --- /dev/null +++ b/docs/src/guides/multiscale/concepts.md @@ -0,0 +1,31 @@ +# How Multiscale Composite Models Execute + +One application executes once for every object selected by `AppliesTo`. State +belongs to the object, while topology and labels belong to the scenario. +`Self()` is the current object, `SelfPlant()` is its plant-instance root, and +`SceneScope()` is model-wide. Cardinality wrappers decide whether zero, one, +or many matches are valid. + +More objects mean more qualified streams. Removing an object stops future +execution but preserves its accepted historical samples. + +Canonical selector patterns are: + +| Relationship | Pattern | +| --- | --- | +| application targets every leaf | `AppliesTo(Many(scale=:Leaf))` | +| input from this same object | omit `Inputs` when the producer is unique | +| input from one ancestor | `One(Ancestor(scale=:Plant))` | +| input from this plant's leaves | `Many(scale=:Leaf, within=SelfPlant())` | +| input from shared soil | `One(scale=:Soil, within=SceneScope())` | +| optional named organ | `OptionalOne(name=:fruit, within=SelfPlant())` | + +`Self()` always means the current target object. It never implicitly means the +model, process, species, or plant. Prefer object IDs and labels for identity, +and use `Scope(name)` only when the model explicitly defines that scope. + +One application produces a separate stream for every selected object and +output variable. Stream keys also include application identity, so repeated +applications of the same process cannot overwrite each other. Use +`explain_applications`, `explain_objects`, and `explain_bindings` to +verify target and source multiplicities before a long run. diff --git a/docs/src/guides/multiscale/from_one_object.md b/docs/src/guides/multiscale/from_one_object.md new file mode 100644 index 000000000..3ebfa4ca1 --- /dev/null +++ b/docs/src/guides/multiscale/from_one_object.md @@ -0,0 +1,10 @@ +# From One Object To A Multiscale CompositeModel + +First run all models on one object with the concise `CompositeModel(models...; +status=...)` constructor. Then move organ-specific applications to leaf +objects and aggregate their outputs on a plant with `Many(..., +within=SelfPlant())` or an instance-local selector. + +Compare collected, application-qualified outputs with `isapprox`. Exact bitwise +identity is not generally a valid requirement after changing reduction order. + diff --git a/docs/src/guides/multiscale/import_mtg.md b/docs/src/guides/multiscale/import_mtg.md new file mode 100644 index 000000000..a5c01cb57 --- /dev/null +++ b/docs/src/guides/multiscale/import_mtg.md @@ -0,0 +1,10 @@ +# Importing An MTG + +`objects_from_mtg(root)` converts MTG topology and labels into ordinary model +objects. `CompositeModel(root; applications=...)` performs the same adaptation and then +uses the normal CompositeModel compiler. The MTG is an input representation, not a +second runtime. + +For growth, prefer `add_organ!`: it creates the MTG node, applies the model's +status policy, attaches status, and registers the corresponding object. + diff --git a/docs/src/guides/multiscale/manual_calls.md b/docs/src/guides/multiscale/manual_calls.md new file mode 100644 index 000000000..c4a717000 --- /dev/null +++ b/docs/src/guides/multiscale/manual_calls.md @@ -0,0 +1,14 @@ +# Manual Calls Across Objects + +Declare parent-owned execution with `Calls(:name => One(...))` or +`Calls(:name => Many(...))`. In the kernel, execute every resolved target with +`run_call!(extra, :name)`. The returned `CallTargets` collection is always +vector-like, including for `One` and `OptionalOne`. Use +`call_targets(extra, :name)` followed by `run_call!(target)` for selective, +per-target, or iterative execution. A target used only by calls is absent from +root scheduling. + +Explicit target cadence must match the caller. A target without an explicit +cadence inherits the caller's invocation timing. Structural mutations are +recompiled between timesteps, so new or removed objects affect calls on the +next timestep, never recursively inside the mutation-producing kernel call. diff --git a/docs/src/guides/multiscale/value_coupling.md b/docs/src/guides/multiscale/value_coupling.md new file mode 100644 index 000000000..ac3bb0333 --- /dev/null +++ b/docs/src/guides/multiscale/value_coupling.md @@ -0,0 +1,10 @@ +# Coupling Values Across Objects + +- `One(...)` requires exactly one source. +- `OptionalOne(...)` accepts zero or one. +- `Many(...)` supplies a stable object-ID ordered carrier. + +Use `var=` to rename a source and `application=` to distinguish repeated +processes. Homogeneous many-source values use a `RefVector`; heterogeneous +values use an object-aware reference carrier. Inspect both through +`input_carrier`, `input_value`, and `explain_bindings`, not internal fields. diff --git a/docs/src/guides/multiscale/visualizing_structure.md b/docs/src/guides/multiscale/visualizing_structure.md new file mode 100644 index 000000000..1651d1679 --- /dev/null +++ b/docs/src/guides/multiscale/visualizing_structure.md @@ -0,0 +1,6 @@ +# Visualizing Composite model Structure + +Use `explain_objects`, `explain_scopes`, and `explain_instances` to +obtain stable rows for plotting. Draw nodes by object ID and edges from parent +to child; color by scale, species, or instance. Keep simulation visualization +outside model kernels so model code remains independent of topology packages. diff --git a/docs/src/guides/time/advanced_time_environment.md b/docs/src/guides/time/advanced_time_environment.md new file mode 100644 index 000000000..4d2da7449 --- /dev/null +++ b/docs/src/guides/time/advanced_time_environment.md @@ -0,0 +1,12 @@ +# Advanced Time And Environment Configuration + +Disambiguate a producer with an explicit selector containing `application`, +`var`, and `within`. Put temporal `policy` and `window` on that input. Configure +environment source renaming and reducers with `Environment`; scenario values +override model-level `meteo_hint` entries. + +Use `explain_bindings`, `explain_environment_bindings`, and +`explain_schedule` to inspect the final source, reducer, window, cadence, and +clock origin. All periods that require seconds must be fixed `Dates` periods; +`Month(1)` is intentionally rejected. + diff --git a/docs/src/guides/time/hourly_daily_weekly.md b/docs/src/guides/time/hourly_daily_weekly.md new file mode 100644 index 000000000..35e04abf5 --- /dev/null +++ b/docs/src/guides/time/hourly_daily_weekly.md @@ -0,0 +1,58 @@ +# Hourly, Daily, And Weekly Models + +Use one hourly leaf application, a daily plant application with +`Many(scale=:Leaf, within=Subtree(), policy=Integrate(), window=Day(1))`, and a +weekly application consuming the daily stream. Each application remains a +normal `ModelSpec`; only its `TimeStep` and input policy differ. + +Runtime dependency streams are retained because consumers need them. Output +resampling is independent: create named `OutputRequest`s for hourly, daily, +and weekly analysis, then compare `explain_schedule` sample counts with rows +from `collect_outputs`. + +The following reduced example checks the important physical contract: two +leaf rates are integrated independently for 24 hourly samples and then summed +on their plant. + +```@example hourly-daily +using Dates +using PlantSimEngine + +PlantSimEngine.@process "docs_hourly_flux" verbose = false +PlantSimEngine.@process "docs_daily_total" verbose = false +struct DocsHourlyFlux <: AbstractDocs_Hourly_FluxModel end +struct DocsDailyTotal <: AbstractDocs_Daily_TotalModel end +PlantSimEngine.inputs_(::DocsHourlyFlux) = (rate=0.0,) +PlantSimEngine.outputs_(::DocsHourlyFlux) = (flux=0.0,) +PlantSimEngine.run!(::DocsHourlyFlux, models, status, meteo, constants, extra) = + (status.flux = status.rate) +PlantSimEngine.inputs_(::DocsDailyTotal) = (fluxes=[0.0],) +PlantSimEngine.outputs_(::DocsDailyTotal) = (total=0.0,) +PlantSimEngine.run!(::DocsDailyTotal, models, status, meteo, constants, extra) = + (status.total = sum(status.fluxes)) + +model = CompositeModel( + Object(:plant; scale=:Plant), + Object(:leaf_1; scale=:Leaf, parent=:plant, status=Status(rate=1.0)), + Object(:leaf_2; scale=:Leaf, parent=:plant, status=Status(rate=2.0)); + applications=( + ModelSpec(DocsHourlyFlux(); name=:hourly) |> + AppliesTo(Many(scale=:Leaf)) |> TimeStep(Hour(1)), + ModelSpec(DocsDailyTotal(); name=:daily) |> + AppliesTo(One(scale=:Plant)) |> + Inputs(:fluxes => Many( + scale=:Leaf, within=Subtree(), application=:hourly, var=:flux, + policy=Integrate(), window=Day(1), + )) |> TimeStep(Day(1)), + ), + environment=[(duration=Hour(1),) for _ in 1:25], +) +simulation = run!(model; steps=25) +@assert only(object.status.total for object in model_objects(model) + if object.id == ObjectId(:plant)) == 72.0 +``` + +A weekly consumer uses the same pattern with `TimeStep(Week(1))` and a +seven-day window over the daily application. Keep `Integrate` for rates; +choose `Aggregate(reducer)` for states or observations whose physical meaning +is a mean, minimum, maximum, or custom statistic. diff --git a/docs/src/guides/time/multirate_concepts.md b/docs/src/guides/time/multirate_concepts.md new file mode 100644 index 000000000..d95a93b11 --- /dev/null +++ b/docs/src/guides/time/multirate_concepts.md @@ -0,0 +1,13 @@ +# Understanding Model Cadence + +`TimeStep(Dates.Period)` sets application cadence. Without it, an application +uses `timespec(model)` when non-default and otherwise the environment base +step. `timestep_hint` validates a scientifically acceptable range; it does not +silently change cadence. + +Temporal input policies have different physical meanings: `HoldLast` samples +the latest state, `Aggregate` reduces observations, and `Integrate` multiplies +rates by sample durations. `PreviousTimeStep` deliberately breaks a same-step +cycle. Windows are rolling fixed-duration windows. Civil-day or +previous-complete-calendar-period alignment is not a supported public feature. + diff --git a/docs/src/index.md b/docs/src/index.md index 631157d41..695df448f 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -2,33 +2,6 @@ CurrentModule = PlantSimEngine ``` -```@setup readme -using PlantSimEngine, PlantMeteo, Dates - -# Import the examples defined in the `Examples` sub-module: -using PlantSimEngine.Examples - -# Import the example meteorological data: -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -# Define the model mapping: -model = ModelMapping( - ToyLAIModel(); - status=(TT_cu=1.0:2000.0,), # Pass the cumulated degree-days as input to the model -) - -out = run!(model) - -# Define the mapping for coupled models: -model2 = ModelMapping( - ToyLAIModel(), - Beer(0.6); - status=(TT_cu=cumsum(meteo_day[:, :TT]),), # Pass the cumulated degree-days as input to `ToyLAIModel`, this could also be done using another model -) -out2 = run!(model2, meteo_day) - -``` - # PlantSimEngine [![Build Status](https://github.com/VirtualPlantLab/PlantSimEngine.jl/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/VirtualPlantLab/PlantSimEngine.jl/actions/workflows/CI.yml?query=branch%3Amain) @@ -40,323 +13,287 @@ out2 = run!(model2, meteo_day) ```@contents Pages = ["index.md"] -Depth = 5 +Depth = 4 ``` -## Overview - -`PlantSimEngine` is a comprehensive framework for building models of the soil-plant-atmosphere continuum. It includes everything you need to **prototype, evaluate, test, and deploy** plant/crop models at any scale, with a strong emphasis on performance and efficiency, so you can focus on building and refining your models. - -**Why choose PlantSimEngine?** - -- **Simplicity**: Write less code, focus on your model's logic, and let the framework handle the rest. -- **Modularity**: Each model component can be developed, tested, and improved independently. Assemble complex simulations by reusing pre-built, high-quality modules. -- **Standardisation**: Clear, enforceable guidelines ensure that all models adhere to best practices. This built-in consistency means that once you implement a model, it works seamlessly with others in the ecosystem. -- **Optimised Performance**: Don't re-invent the wheel. Delegating low-level tasks to PlantSimEngine guarantees that your model will benefit from every improvement in the framework. Enjoy faster prototyping, robust simulations, and efficient execution using Julia's high-performance capabilities. - -## Unique Features - -### Automatic Model Coupling - -**Seamless Integration:** PlantSimEngine leverages Julia's multiple-dispatch capabilities to automatically compute the dependency graph between models. This allows researchers to effortlessly couple models without writing complex connection code or manually managing dependencies. - -**Intuitive Multi-Scale Support:** The framework naturally handles models operating at different scales—from organelle to ecosystem—connecting them with minimal effort and maintaining consistency across scales. - -### Flexibility with Precision Control - -**Effortless Model Switching:** Researchers can switch between different component models using a simple syntax without rewriting the underlying model code. This enables rapid comparison between different hypotheses and model versions, accelerating the scientific discovery process. - -### Multi-rate Execution +!!! warning "Configuration API migration" + New multiscale, multi-plant, soil, model, and microclimate scenarios should + use the unified `CompositeModel`/`Object` API with `AppliesTo`, `Inputs`, `Calls`, + `Updates`, `TimeStep`, and `Environment`. See + [Migrating To The CompositeModel/Object API](migration_composite_model.md). Superseded + mapping constructors and their implementation have been removed. -**Mix model cadences in one simulation:** PlantSimEngine can run models at different timesteps within the same MTG simulation. This makes it possible to combine, for example, hourly leaf processes with daily plant balances and weekly reporting models without writing custom scheduling glue. - -**Explicit bindings between rates:** `TimeStepModel`, `InputBindings`, `MeteoBindings`, `ScopeModel`, and `OutputRequest` let you declare how model inputs, meteorology, and exported outputs should behave when rates differ. - -## Batteries included - -- **Automated Management**: Seamlessly handle inputs, outputs, time-steps, objects, and dependency resolution. -- **Iterative Development**: Fast and interactive prototyping of models with built-in constraints to avoid errors and sensible defaults to streamline the model writing process. -- **Control Your Degrees of Freedom**: Fix variables to constant values or force to observations, use simpler models for specific processes to reduce complexity. -- **Multi-Rate Scheduling**: Combine hourly, daily, and coarser models in the same simulation, with explicit policies for input aggregation and meteorological sampling. -- **High-Speed Computations**: Achieve impressive performance with benchmarks showing operations in the 100th of nanoseconds range for complex models (see this [benchmark script](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples/benchmark.jl)). -- **Parallelize and Distribute Computing**: Out-of-the-box support for sequential, multi-threaded, or distributed computations over objects, time-steps, and independent processes, thanks to [Floops.jl](https://juliafolds.github.io/FLoops.jl/stable/). -- **Scale Effortlessly**: Methods for computing over objects, time-steps, and [Multi-Scale Tree Graphs](https://github.com/VEZY/MultiScaleTreeGraph.jl). -- **Compose Freely**: Use any types as inputs, including [Unitful](https://github.com/PainterQubits/Unitful.jl) for unit propagation and [MonteCarloMeasurements.jl](https://github.com/baggepinnen/MonteCarloMeasurements.jl) for measurement error propagation. - -## Performance - -PlantSimEngine delivers impressive performance for plant modeling tasks. On an M1 MacBook Pro: +## Overview -- A toy model for leaf area over a year at daily time-scale took only 260 μs (about 688 ns per day) -- The same model coupled to a light interception model took 275 μs (756 ns per day) +`PlantSimEngine` is a Julia framework for building soil-plant-atmosphere +simulations from small process models. A modeler writes reusable kernels with +`inputs_`, `outputs_`, optional dependency traits, and `run!`. A simulation +author then assembles those kernels on objects in a `CompositeModel`. -These benchmarks demonstrate performance on par with compiled languages like Fortran or C, far outpacing typical interpreted language implementations. For example, PlantBiophysics.jl, which implements ecophysiological models using PlantSimEngine, has been measured to run up to 38,000 times faster than equivalent implementations in other scientific computing languages. +The current public scenario API is organized around: -## Ask Questions +```julia +CompositeModel +Object +ModelSpec +AppliesTo +Inputs +Calls +Updates +TimeStep +Environment +``` -If you have any questions or feedback, [open an issue](https://github.com/VirtualPlantLab/PlantSimEngine.jl/issues) or ask on [discourse](https://fspm.discourse.group/c/software/virtual-plant-lab). +### Models And Applications + +A model is a reusable implementation of a process. A model application is one +configured use of that model in a model: it gives the use a name, selects its +target objects, and configures its inputs, calls, timestep, and environment. + +| Concept | Meaning | +|:--|:--| +| Process | The biological or physical operation, such as light interception | +| Model | An implementation of that process, such as `Beer` | +| Application | One configured use of a model in a `CompositeModel` | +| Target | An object on which that application executes | + +One application can target many objects. The same model can also be used in +several named applications with different parameters, selectors, or cadence. +During compilation, PlantSimEngine resolves each application into its concrete +`(application, object)` executions. + +This means the same model can be reused on one object, many leaves, several +plant species, a shared soil object, or a model-scale energy-balance solver +without changing the model implementation. + +## Why PlantSimEngine? + +- **Modular models**: each process model can be developed, tested, calibrated, + and replaced independently. +- **Explicit coupling**: `Inputs(...)` declares value dependencies, while + `Calls(...)` gives iterative parent solvers manual control over hard model + calls. +- **Object-based multiscale composite models**: scales are labels on objects, so a plant + can be described as plants, axes, internodes, leaves, roots, voxels, or any + topology the model requires. +- **Multirate execution**: use `TimeStep(Dates.Hour(1))`, + `TimeStep(Dates.Day(1))`, and temporal policies such as `Integrate()` or + `HoldLast()` in the same model. +- **Automatic environment binding**: global weather and spatial microclimate + backends are bound through `Environment(...)` and model `meteo_inputs_` / + `meteo_outputs_` traits. +- **Performance-oriented internals**: selectors and bindings are compiled + before the timestep loop, same-rate inputs use references when possible, and + homogeneous object batches are specialized. +- **Generic values**: status, model parameters, meteorology, and outputs can + carry units, automatic-differentiation values, uncertainty wrappers, or other + compatible Julia types. ## Installation -To install the package, enter the Julia package manager mode by pressing `]` in the REPL, and execute the following command: +To install the package, enter Julia package mode by pressing `]` in the REPL, +then run: ```julia add PlantSimEngine ``` -To use the package, execute this command from the Julia REPL: +Use it from Julia with: ```julia using PlantSimEngine ``` -## Example usage +## Quickstart: One CompositeModel Object -The package is designed to be easy to use, and to help users avoid errors when implementing, coupling and simulating models. +This example runs three existing toy models on one model object: -### Simple example +1. `ToyDegreeDaysCumulModel` computes daily thermal time. +2. `ToyLAIModel` consumes cumulative thermal time and computes LAI. +3. `Beer` consumes LAI and meteorology to compute absorbed PAR. -Here's a simple example of a model that simulates the growth of a plant, using a simple exponential growth model: +The model kernels are unchanged; the model application layer says where they +run. Since no `TimeStep` is specified, these applications use the daily +cadence of `meteo_day`. ```@example readme -# ] add PlantSimEngine -using PlantSimEngine - -# Import the examples defined in the `Examples` sub-module +using PlantSimEngine, PlantMeteo, Dates, DataFrames using PlantSimEngine.Examples -# Define the model mapping: -model = ModelMapping( - ToyLAIModel(); - status=(TT_cu=1.0:2000.0,), # Pass the cumulated degree-days as input to the model +meteo_day = read_weather( + joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"); + duration=Dates.Day, ) -out = run!(model) # run the model and extract its outputs - -out[1:3,:] -``` - -> **Note** -> The `ToyLAIModel` is available from the [examples folder](https://github.com/VirtualPlantLab/PlantSimEngine.jl/tree/main/examples), and is a simple exponential growth model. It is used here for the sake of simplicity, but you can use any model you want, as long as it follows `PlantSimEngine` interface. - -Of course you can plot the outputs quite easily: - -```@example readme -# ] add CairoMakie -using CairoMakie - -lines(out[:TT_cu], out[:LAI], color=:green, axis=(ylabel="LAI (m² m⁻²)", xlabel="Cumulated growing degree days since sowing (°C)")) -``` - -### Model coupling - -Model coupling is done automatically by the package, and is based on the dependency graph between the models. To couple models, we just have to add them to the `ModelMapping`. For example, let's couple the `ToyLAIModel` with a model for light interception based on Beer's law: - -```@example readme -# ] add PlantSimEngine, PlantMeteo -using PlantSimEngine, PlantMeteo, Dates - -# Import the examples defined in the `Examples` sub-module -using PlantSimEngine.Examples - -# Import the example meteorological data: -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -# Define the mapping for coupled models: -model2 = ModelMapping( +model = CompositeModel( + ToyDegreeDaysCumulModel(), ToyLAIModel(), Beer(0.6); - status=(TT_cu=cumsum(meteo_day[:, :TT]),), # Pass the cumulated degree-days as input to `ToyLAIModel`, this could also be done using another model + environment=meteo_day, ) -# Run the simulation: -out2 = run!(model2, meteo_day) -out2[1:3,:] +sim = run!(model; steps=30, outputs=:all) +out = collect_outputs(sim; sink=DataFrame) +first(out, 6) ``` -The `ModelMapping` couples the models by automatically computing the dependency graph of the models. The resulting dependency graph is: +`ToyLAIModel` does not know where `TT_cu` comes from, and `Beer` does not know +where `LAI` comes from. The compiler infers the unambiguous same-object +bindings from each model's declared inputs and outputs: -``` -╭──── Dependency graph ──────────────────────────────────────────╮ -│ ╭──── LAI_Dynamic ─────────────────────────────────────────╮ │ -│ │ ╭──── Main model ────────╮ │ │ -│ │ │ Process: LAI_Dynamic │ │ │ -│ │ │ Model: ToyLAIModel │ │ │ -│ │ │ Dep: nothing │ │ │ -│ │ ╰────────────────────────╯ │ │ -│ │ │ ╭──── Soft-coupled model ─────────╮ │ │ -│ │ │ │ Process: light_interception │ │ │ -│ │ └──│ Model: Beer │ │ │ -│ │ │ Dep: (LAI_Dynamic = (:LAI,),) │ │ │ -│ │ ╰─────────────────────────────────╯ │ │ -│ ╰──────────────────────────────────────────────────────────╯ │ -╰────────────────────────────────────────────────────────────────╯ +```@example readme +select( + DataFrame(explain_bindings(model)), + :application_id, + :input, + :source_application_ids, + :carrier_kind, + :copy_semantics, +) ``` -We can plot the results by indexing the outputs with the variable name (e.g. `out2[:LAI]`): +The outputs can be plotted like any other tabular result: ```@example readme using CairoMakie +lai = out[out.variable .== :LAI, :value] +appfd = out[out.variable .== :aPPFD, :value] +tt_cu = out[out.variable .== :TT_cu, :value] + fig = Figure(resolution=(800, 600)) ax = Axis(fig[1, 1], ylabel="LAI (m² m⁻²)") -lines!(ax, out2[:TT_cu], out2[:LAI], color=:mediumseagreen) +lines!(ax, tt_cu, lai, color=:mediumseagreen) ax2 = Axis(fig[2, 1], xlabel="Cumulated growing degree days since sowing (°C)", ylabel="aPPFD (mol m⁻² d⁻¹)") -lines!(ax2, out2[:TT_cu], out2[:aPPFD], color=:firebrick1) - +lines!(ax2, tt_cu, appfd, color=:firebrick1) fig ``` -### Multi-scale modeling - -> See the [Multi-scale modeling](#multi-scale-modeling) section for more details. +## Multi-Object Inputs -The package is designed to be easily scalable, and can be used to simulate models at different scales. For example, you can simulate a model at the leaf scale, and then couple it with models at any other scale, *e.g.* internode, plant, soil, scene scales. Here's an example of a simple model that simulates plant growth using sub-models operating at different scales: +Use `Inputs(...)` when a model needs values from selected objects. Here the +model-scale LAI model reads live references to all plant surfaces in the model: ```@example readme -mapping = ModelMapping( - :Scene => ToyDegreeDaysCumulModel(), - :Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => :Scene, - ], - ), - Beer(0.6), - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => :Soil], - ), - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - ), - :Internode => ( - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => :Scene,], - ), - MultiScaleModel( - model=ToyInternodeEmergence(TT_emergence=20.0), - mapped_variables=[:TT_cu => :Scene], - ), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(carbon_biomass=1.0) +plant_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, parent=:scene, + status=Status(surface=12.0)), + Object(:plant_2; scale=:Plant, kind=:plant, parent=:scene, + status=Status(surface=8.0)); + applications=( + ModelSpec(ToyLAIfromLeafAreaModel(100.0); name=:scene_lai) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :plant_surfaces => Many( + scale=:Plant, + within=SceneScope(), + var=:surface, + ), + ), ), - :Leaf => ( - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => :Scene,], - ), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - Status(carbon_biomass=1.0) - ), - :Soil => ( - ToySoilWaterModel(), - ), -); -nothing # hide -``` - -We can import an example plant from the package: - -```@example readme -mtg = import_mtg_example() -``` - -Make a fake meteorological data: - -```@example readme -meteo = Weather( - [ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f=300.0), - Atmosphere(T=25.0, Wind=0.5, Rh=0.8, Ri_PAR_f=500.0) -] -); -nothing # hide -``` - -And run the simulation: - -```@example readme -out_vars = Dict( - :Scene => (:TT_cu,), - :Plant => (:carbon_allocation, :carbon_assimilation, :soil_water_content, :aPPFD, :TT_cu, :LAI), - :Leaf => (:carbon_demand, :carbon_allocation), - :Internode => (:carbon_demand, :carbon_allocation), - :Soil => (:soil_water_content,), ) -out = run!(mtg, mapping, meteo, tracked_outputs=out_vars, executor=SequentialEx()); -nothing # hide -``` - -We can then extract the outputs and convert them to a `DataFrame` for each scale and sort them: - -```@example readme -using DataFrames -df_outputs = convert_outputs(out, DataFrame) -leaf_df = df_outputs isa AbstractDict ? df_outputs[:Leaf] : df_outputs -sort!(leaf_df, [:timestep, :node]) +run!(plant_scene) +scene_status = only(model_objects(plant_scene; scale=:Scene)).status +scene_status ``` -An example output of a multiscale simulation is shown in the documentation of PlantBiophysics.jl: - -![Plant growth simulation](www/image.png) - -### Multi-rate modeling +The same `Many(...)` selector would be plant-local if the consumer ran on a +plant and used `within=Subtree()`. This is the same mechanism used for plant +allocation models that sum their own leaves, model models that aggregate all +plants, and microclimate solvers that select objects inside one environment +cell. -PlantSimEngine also supports multi-rate MTG simulations, where different models run at different cadences inside the same execution. A typical use case is to run leaf-scale processes hourly, aggregate them into daily plant-scale balances, and then export weekly summary series from the same simulation. +When a selector reads a value produced by another model application, prefer +`application=...` to identify the concrete producer. A process name describes +the reusable scientific contract; an application name identifies the mounted +producer in this scenario. This matters as soon as several applications +implement the same process or publish the same variable on different objects. -The dedicated documentation now has three pages: a short introduction to the -core ideas, a fuller step-by-step tutorial, and an advanced configuration page: +## Manual Calls For Iterative Solvers -- [Introduction to multi-rate execution](./multirate/introduction.md) -- [Step-by-step hourly, daily, weekly simulation](./multirate/multirate_tutorial.md) -- [Advanced multi-rate configuration](./multirate/advanced_configuration.md) +Use `Calls(...)` when a parent model must directly run another model, for +example a model energy-balance solver that iterates leaf temperatures until +convergence: -## State of the field +```julia +ModelSpec(SceneEnergyBalance(); name=:scene_energy) |> + AppliesTo(One(scale=:Scene)) |> + Calls( + :leaf_energy => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:energy_balance, + ), + :soil => One( + kind=:soil, + scale=:Soil, + within=SceneScope(), + application=:soil_water, + ), + ) |> + TimeStep(Hour(1)) +``` -PlantSimEngine is a state-of-the-art plant simulation software that offers significant advantages over existing tools such as OpenAlea, STICS, APSIM, or DSSAT. +The same rule applies to manual calls: scenario wiring should select the +concrete callee application with `application=...`. Model authors use process +requirements in `dep(model)` when they declare generic dependencies, because +they cannot know the application names that a user will choose later. + +Inside `run!`, use `run_call!(extra, :leaf_energy)` to execute every target and +receive a vector-like collection. Iterative parents use +`call_targets(extra, :leaf_energy)` and `run_call!(target; publish=false)` for +trial iterations. The accepted state uses `run_call!(target; publish=true)` +once, so temporal outputs and mutable environment writes are published exactly +once. + +## Where To Go Next + +- [CompositeModel/Object Quickstart](composite_model/quickstart.md) gives a compact + runnable workflow using the new API. +- [Migrating To The CompositeModel/Object API](migration_composite_model.md) translates + historical `ModelMapping` and `MultiScaleModel` examples. +- [Public API](API/API_public.md) lists the composite-model/object constructors, + selectors, lifecycle hooks, and explanation helpers. +- [Model traits](model_traits.md) explains `inputs_`, `outputs_`, `dep`, + `timespec`, `output_policy`, `meteo_inputs_`, and `meteo_outputs_`. -The use of Julia programming language in PlantSimEngine allows for: +## Performance -- Quick and easy prototyping compared to compiled languages -- Significantly better performance than typical interpreted languages -- No need for translation into another compiled language +PlantSimEngine keeps model kernels close to regular Julia functions while the +runtime handles dependency scheduling, object selection, temporal aggregation, +and environment sampling. On an M1 MacBook Pro, toy daily simulations run in +hundreds of microseconds, and PlantBiophysics.jl models using PlantSimEngine +have been measured much faster than equivalent implementations in typical +scientific scripting languages. -Julia's features enable PlantSimEngine to provide: +For performance-sensitive composite models, inspect the supported structured +explanations: -- Multiple-dispatch for automatic computation of model dependency graphs -- Type stability for optimized performance -- Seamless compatibility with powerful tools like MultiScaleTreeGraph.jl for multi-scale computations +```julia +explain_bindings(model) +explain_schedule(model) +explain_execution_plan(model) +``` -PlantSimEngine's approach streamlines the process of model development by automatically managing: +These helpers expose resolved objects, carriers, copy/reference semantics, +application clocks, and homogeneous execution batches. -- Model coupling with automated dependency graph computation -- Time-steps and parallelization -- Input and output variables -- Various types of objects used for simulations (vectors, dictionaries, multi-scale tree graphs) +## Ask Questions -## Projects that use PlantSimEngine +If you have questions or feedback, [open an issue](https://github.com/VirtualPlantLab/PlantSimEngine.jl/issues) +or ask on [discourse](https://fspm.discourse.group/c/software/virtual-plant-lab). -Take a look at these projects that use PlantSimEngine: +## Projects That Use PlantSimEngine - [PlantBiophysics.jl](https://github.com/VEZY/PlantBiophysics.jl) - [XPalm](https://github.com/PalmStudio/XPalm.jl) -## Make it yours - -The package is developed so anyone can easily implement plant/crop models, use it freely and as you want thanks to its MIT license. +## Make It Yours -If you develop such tools and it is not on the list yet, please make a PR or contact me so we can add it! 😃 +PlantSimEngine is distributed under the MIT license. If you develop a package +or model suite that uses it and want it listed here, please open a pull request +or contact the maintainers. diff --git a/docs/src/introduction/why_plantsimengine.md b/docs/src/introduction/why_plantsimengine.md index ed8759658..6813f1051 100644 --- a/docs/src/introduction/why_plantsimengine.md +++ b/docs/src/introduction/why_plantsimengine.md @@ -82,7 +82,10 @@ PlantSimEngine's approach to plant modeling represents a paradigm shift in how s - **Automatic Dependency Resolution:** The system automatically determines the relationships between different models and processes, eliminating the need for manual coupling. -- **Seamless Parallelization:** Out-of-the-box support for parallel and distributed computation allows researchers to focus on the science rather than implementation details. +- **Concrete Batched Execution:** The model compiler groups compatible object + targets into concrete execution batches. A public parallel or distributed + executor is not currently provided; parallel execution remains planned work + that requires explicit correctness and independence guarantees. - **Flexible Model Integration:** The ability to easily combine models from different sources and at different scales facilitates more comprehensive and realistic simulations. diff --git a/docs/src/migration_composite_model.md b/docs/src/migration_composite_model.md new file mode 100644 index 000000000..2a7b8f049 --- /dev/null +++ b/docs/src/migration_composite_model.md @@ -0,0 +1,505 @@ +# Migrating To The CompositeModel/Object API + +## Refining early CompositeModel/Object code + +The stabilized public surface makes several early CompositeModel/Object behaviors +explicit: + +| Early spelling or behavior | Stabilized 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` | +| `tracked_outputs=requests` | `outputs=requests` | +| `OutputRequest(:Leaf, :x; process=:p)` | `OutputRequest(Many(scale=:Leaf), :x; application=:app)` | +| repeated unnamed applications gained numbered IDs | name every repeated application with `ModelSpec(...; name=...)` | +| calling `run!(model)` again implicitly looked like continuation | use `continue!(simulation)` or `step!(simulation)` | +| compiler/cache types imported from the default namespace | qualify them through `PlantSimEngine.Advanced` | + +`tracked_outputs` remains a targeted deprecation bridge. It maps `nothing` to +`outputs=:all`, an empty request vector to `outputs=:none`, and requests to the +equivalent `outputs` value. `process=` in `OutputRequest` is likewise a bridge +to application-qualified requests. Singular scenario-level `Inputs` and +`Calls` also deprecate process-only filters; model-authored `Input`/`Call` +defaults may still discover a process because they cannot know scenario +application names. `Many(process=...)` remains an explicit discovery query for +collecting several applications, such as the mounted applications from several +instances. New singular scenario references should use `application=`. The +deprecated bridges are scheduled for removal in PlantSimEngine 0.15. + +Calling `run!(model; ...)` always creates a fresh result timeline starting at +step one, even when object status has already been mutated by an earlier run. +Continue the same timeline, environment position, temporal histories, and +multirate phase with: + +```julia +simulation = run!(model; steps=24, outputs=requests) +continue!(simulation; steps=24) +step!(simulation) +@assert current_step(simulation) == 49 +``` + +The composite-model/object API replaces the historical multiscale mapping system with +one object-address graph. + +New scenario code should be organized around: + +```julia +CompositeModel +Object +ModelSpec +AppliesTo +Inputs +Calls +Updates +TimeStep +Environment +``` + +Process-model implementations do not need to know about composite models, plants, objects, or +timesteps. They keep the existing kernel contract: + +```julia +inputs_(model) +outputs_(model) +dep(model) +meteo_inputs_(model) +meteo_outputs_(model) +run!(model, models, status, meteo, constants, extra) +``` + +This page maps the legacy configuration concepts to their composite-model/object +equivalents. + +## Scenario Structure + +Legacy simulations split configuration between `ModelMapping` and +`MultiScaleModel`. The unified API stores runtime entities in one `CompositeModel`: + +`ModelMapping` has been removed. Historical code must be translated to the +composite-model/object form below. + +```julia +model = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, parent=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:plant_1), + Object(:soil; scale=:Soil, kind=:soil, parent=:scene); + applications=( + ModelSpec(LeafModel(); name=:leaf_model) |> + AppliesTo(Many(scale=:Leaf)), + + ModelSpec(SoilModel(); name=:soil_model) |> + AppliesTo(One(scale=:Soil)), + ), + environment=(T=25.0, Rh=0.6, Wind=1.0), +) +``` + +`Object` labels describe runtime entities. They do not prescribe plant +topology. A plant may use any hierarchy of plants, axes, internodes, segments, +leaves, roots, fruits, or application-specific objects. + +### Existing MTG Topologies + +An existing MTG can be adapted without rebuilding its topology manually: + +```julia +model = CompositeModel( + mtg; + applications=applications, + environment=meteo, + kind=node_kind, + species=node_species, + geometry=node_geometry, +) +``` + +`objects_from_mtg(mtg; ...)` exposes the intermediate object list when it is +useful to inspect or modify labels before constructing the model. By default, +the adapter uses MTG node ids and scales, and reuses an existing +`:plantsimengine_status` attribute when present. + +## Multiscale Inputs + +Replace `MultiScaleModel(...)` variable mappings with consumer-side +`Inputs(...)`. + +Legacy: + +```julia +MultiScaleModel( + AllocationModel(), + [:leaf_carbon => [:Leaf => :leaf_carbon]], +) +``` + +Unified: + +```julia +ModelSpec(AllocationModel(); name=:allocation) |> + AppliesTo(Many(scale=:Plant)) |> + Inputs( + :leaf_carbon => Many( + scale=:Leaf, + within=Subtree(), + var=:leaf_carbon, + ), + ) +``` + +`Self()` selects only the object where the consumer runs. A plant-scale +allocation model uses `Subtree()` to read leaves below that plant. Use +`SceneScope()` for model-wide aggregation and `SelfPlant()` to select the +nearest containing plant and its subtree from an organ. + +Same-object renaming uses the same syntax: + +```julia +Inputs( + :consumer_name => One( + within=Self(), + application=:producer, + var=:producer_name, + ), +) +``` + +Same-rate bindings use shared references or reference vectors when possible. +Cross-rate bindings use typed temporal streams. + +## CompositeModel-Wide Values + +Use an input selector on the consuming application: + +```julia +ModelSpec(SceneWaterBalance(); name=:scene_water) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :leaf_transpiration => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:transpiration, + var=:transpiration, + ), + ) +``` + +The compiler chooses the carrier. Scenario authors declare the source objects, +source variable, and temporal policy rather than a route implementation. + +## Manual Hard Calls + +Use `Calls(...)` when a parent model must control child execution. + +```julia +ModelSpec(SceneEnergyBalance(); name=:scene_energy) |> + AppliesTo(One(scale=:Scene)) |> + Calls( + :leaf_energy => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:energy_balance, + ), + :soil => One( + kind=:soil, + scale=:Soil, + within=SceneScope(), + application=:soil_water, + ), + ) +``` + +The parent model controls execution: + +```julia +function PlantSimEngine.run!(model::SceneEnergyBalance, models, status, meteo, + constants, extra) + leaf_targets = call_targets(extra, :leaf_energy) + + for iteration in 1:model.max_iterations + for target in leaf_targets + run_call!(target; meteo=trial_meteo(model, status)) + end + converged(model, status, leaf_targets) && break + end + + for target in leaf_targets + run_call!(target; meteo=accepted_meteo(model, status), publish=true) + end + return nothing +end +``` + +`run_call!` defaults to `publish=false`. Trial calls mutate target status but +do not append temporal samples or write environment outputs. The accepted +state must use `publish=true`. + +## Multiple Plants And Species + +Represent repeated plant configurations with `CompositeModelTemplate` and +`ObjectInstance`. + +```julia +oil_palm = CompositeModelTemplate( + ( + ModelSpec(LeafEnergy()) |> + AppliesTo(Many(scale=:Leaf)), + + ModelSpec(Allocation()) |> + AppliesTo(One(scale=:Plant)) |> + Inputs( + :leaf_carbon => Many( + scale=:Leaf, + within=Subtree(), + var=:leaf_carbon, + ), + ), + ); + kind=:plant, + species=:oil_palm, +) + +palm_1 = ObjectInstance( + :palm_1, + oil_palm; + root=Object(:plant_1; scale=:Plant, parent=:scene), + objects=(Object(:palm_1_leaf_1; scale=:Leaf, parent=:plant_1),), +) + +palm_2 = ObjectInstance( + :palm_2, + oil_palm; + root=Object(:plant_2; scale=:Plant, parent=:scene), + objects=(Object(:palm_2_leaf_1; scale=:Leaf, parent=:plant_2),), +) + +model = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + palm_1, + palm_2, +) +``` + +Unmodified instances share model objects and parameters. Use instance +overrides for one plant and `Override(...)` for exceptional organs. + +## Multirate Inputs + +Replace `TimeStepModel(...)` with `TimeStep(...)`. Put temporal policy and +window information on the consuming `Inputs(...)` selector. + +```julia +ModelSpec(HourlyLeafModel(); name=:leaf_flux) |> + AppliesTo(Many(scale=:Leaf)) |> + TimeStep(Hour(1)) + +ModelSpec(DailyPlantModel(); name=:daily_plant) |> + AppliesTo(Many(scale=:Plant)) |> + Inputs( + :leaf_fluxes => Many( + scale=:Leaf, + within=Subtree(), + application=:leaf_flux, + var=:flux, + policy=Integrate(), + window=Day(1), + ), + ) |> + TimeStep(Day(1)) +``` + +Use `HoldLast()`, `Interpolate()`, `Integrate()`, or `Aggregate()` according to +the physical meaning of the input. `PreviousTimeStep(:x) => selector` expresses +an explicit lag and breaks a same-timestep dependency cycle. + +If the input selector omits `policy=...`, the model compiler uses the +producer's `output_policy(...)` trait for the selected source variable when the +publisher is unique. An explicit selector policy always wins over the trait. + +If a model defines `timespec(::Type{<:MyModel})`, the model scheduler uses that +cadence when the application has no explicit `TimeStep(...)`. A scenario-level +`TimeStep(...)` always wins over the model trait. + +If the clock falls back to the model base step, `timestep_hint(...)` required +bounds are validated against that base step. The hint is a compatibility +constraint, not a scheduling override. + +## Ordered Variable Updates + +When several models intentionally write the same variable, declare the order +on the later application: + +```julia +ModelSpec(CarbonAllocation(); name=:allocation) |> + AppliesTo(Many(scale=:Leaf)) + +ModelSpec(LeafPruning(); name=:pruning) |> + AppliesTo(Many(scale=:Leaf)) |> + Updates(:leaf_biomass; after=:allocation) +``` + +Do not encode this coupling in either model implementation. The scenario owns +the writer order. + +## Environment And Microclimate + +Models declare environment variables with `meteo_inputs_` and +`meteo_outputs_`. The model binds each object to the active environment +backend: + +```julia +ModelSpec(LeafEnergy(); name=:leaf_energy) |> + AppliesTo(Many(scale=:Leaf)) |> + Environment(provider=:grid) +``` + +Spatial bindings are cached. The default resolver uses the object's geometry, +then the nearest ancestor geometry, then the backend's global behavior. +Changing geometry invalidates only affected environment bindings. + +Per-model source remapping also moves to `Environment(...)`: + +```julia +ModelSpec(LeafGasExchange(); name=:gas_exchange) |> + AppliesTo(Many(scale=:Leaf)) |> + Environment(provider=:global, sources=(CO2=:Ca,)) +``` + +The model still declares and reads `CO2`; the model samples `Ca` from the +active environment backend and exposes it to the model as `meteo.CO2`. +`explain_environment_bindings(...)` reports both `required_inputs` and +`source_inputs`, so remapped meteorology is visible to users and agents. + +Model authors can also provide default source remaps with +`meteo_hint(::Type{<:Model}) = (bindings=(CO2=(source=:Ca,),),)`. CompositeModel +applications use those defaults when the scenario does not provide an explicit +source for the same variable. `Environment(; sources=...)` remains the +scenario-level override. + +For global `Weather` tables, sampling follows the application's +`TimeStep(...)`. A slower model receives a PlantMeteo windowed sample using its +`meteo_hint` reducer and window instead of receiving only the current raw +weather row. A scenario source override preserves that reducer: + +```julia +meteo_hint(::Type{<:GasExchange}) = ( + bindings=(CO2=(source=:Ca, reducer=MeanReducer()),), +) + +ModelSpec(GasExchange()) |> + AppliesTo(Many(scale=:Leaf)) |> + TimeStep(Hour(2)) |> + Environment(provider=:global, sources=(CO2=:canopy_CO2,)) +``` + +Every leaf still reads `meteo.CO2`; the two-hour mean is computed from +`:canopy_CO2`. The sampled row is computed once per application and timestep, +then reused for all selected leaves. + +## Growth, Pruning, And Movement + +Use the public lifecycle operations: + +```julia +register_object!(model, new_leaf; parent=:plant_1) +new_leaf_status = add_organ!( + parent_node, + model, + :+, + :Leaf, + 3; + initial_status=(biomass=0.0,), +) +remove_object!(model, :old_leaf) +reparent_object!(model, :leaf_2, :axis_3) +move_object!(model, :leaf_3, new_geometry) +``` + +For MTG-backed growth, prefer `add_organ!`: it creates the MTG node and its +model object together and reuses the status initialization policy from +`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 before the next timestep. Geometry-only +changes refresh environment bindings without rebuilding unrelated structural +bindings. + +The refreshed runtime also rebuilds homogeneous execution batches. Use +`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. + +## Output Collection + +`run!(model; steps=...)` returns a `Simulation`. Use +`outputs(sim)` for the retained typed streams, `explain_outputs(sim)` for +structured diagnostics, and `collect_outputs(sim)` for tabular rows. + +```julia +request = OutputRequest( + Many(scale=:Leaf), + :transpiration; + name=:leaf_transpiration_daily, + application=:leaf_energy, + policy=Integrate(), + clock=Day(1), +) + +sim = run!(model; steps=48, outputs=request) +daily = collect_outputs(sim, :leaf_transpiration_daily) +``` + +CompositeModel output requests are materialized from retained temporal streams after +the run. They use the same temporal policies as multirate inputs and export +dynamic objects only over the interval where that object published samples. +If several model applications implement the same process, add +`application=:application_name` to select one explicitly. This is also the +way to request a named `:stream_only` publisher. +`outputs=:none` retains no user streams. Passing explicit requests retains only +their application/variable streams plus streams needed by temporal +`Inputs(...)`. Use `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. + +## Inspecting The Compiled Scenario + +Use structured explanations instead of inspecting internal dictionaries: + +```julia +explain_objects(model) +explain_instances(model) +explain_scopes(model) +explain_applications(model) +explain_bindings(model) +explain_calls(model) +explain_environment_bindings(model) +explain_schedule(model) +explain_writers(model) +explain_model_bundles(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. + +## Migration Table + +| Legacy configuration | CompositeModel/object replacement | +| --- | --- | +| `ModelMapping` scale assembly | `CompositeModel` objects plus model applications | +| `MultiScaleModel(...)` | consumer `Inputs(...)` | +| `TimeStepModel(...)` | `TimeStep(...)` | +| `InputBindings(...)` | source, policy, and window on `Inputs(...)` | +| `MeteoBindings(...)` | automatic environment binding or `Environment(...)` | +| `ScopeModel(...)` | `AppliesTo(...)` and selector scopes | +| `SameScale()` rename | `Inputs(:local => One(within=Self(), var=:source))` | + +The executable MAESPA migration in +`examples/maespa_model_example.jl` demonstrates two plant species, shared +soil, plant-local aggregation, model-wide iterative energy balance, hourly and +daily models, and automatic environment binding. diff --git a/docs/src/model_coupling/model_coupling_user.md b/docs/src/model_coupling/model_coupling_user.md deleted file mode 100644 index b24be6931..000000000 --- a/docs/src/model_coupling/model_coupling_user.md +++ /dev/null @@ -1,173 +0,0 @@ -# Model coupling for users - -```@setup usepkg -using PlantSimEngine, PlantMeteo, Dates -# Import the example models defined in the `Examples` sub-module: -using PlantSimEngine.Examples - -m = ModelMapping( - Process1Model(2.0), - Process2Model(), - Process3Model(), - Process4Model(), - Process5Model(), - Process6Model(), - Process7Model(), -) -``` - -`PlantSimEngine.jl` is designed to make model coupling simple for both the modeler and the user. For example, `PlantBiophysics.jl` implements the [`Fvcb`](https://vezy.github.io/PlantBiophysics.jl/stable/functions/#PlantBiophysics.Fvcb) model to simulate the photosynthesis process. This model needs the stomatal conductance process to be simulated, so it calls again `run!` inside its implementation at some point. Note that it does not force any kind of conductance model over another, just that there is one to simulate the process. This ensures that users can choose whichever model they want to use for this simulation, independent of the photosynthesis model. - -We provide an example script that implements seven dummy processes in [`examples/dummy`](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples/dummy.jl). The processes are simply called "process1", "process2"..., and the model implementations are called `Process1Model`, `Process2Model`... - -## Hard coupled models - -The `Process3Model` calls `Process2Model`, and `Process2Model` calls `Process1Model`. This explicit call is called a hard-dependency in PlantSimEngine. - -The other models for the other processes are called `Process4Model`, `Process5Model`... and they do not call explicitly other models when running, but some outputs of the models are used as inputs of other models. This is called a soft-dependency in PlantSimEngine. - -!!! tip - Hard-coupling of models is usually done when there are some kind of iterative computation in one of the models that depend on one another. This is not the case in our example here as it is obviously just a simple one. In this case the coupling is not really necessary as models could just be called sequentially one after the other. For a more representative example, you can look at the energy balance computation of Monteith in `PlantBiophysics.jl`, which is hard-coupled to a photosynthesis model. - -Back to our example, using `Process3Model` requires a "process2" model, and in our case the only model available is `Process2Model`. The latter also requires a "process1" model, and again we only have one model implementation for this process, which is `Process1Model`. - -Let's use the `Examples` sub-module so we can play around: - -```julia -# Import the example models defined in the `Examples` sub-module: -using PlantSimEngine.Examples -``` - -!!! tip - Use subtype(x) to know which models are available for a process, e.g. for "process1" you can do `subtypes(AbstractProcess1Model)`. - -Here is how we can make the model coupling: - -```@example usepkg -m = ModelMapping(Process1Model(2.0), Process2Model(), Process3Model()) -nothing # hide -``` - -We can see that only the first model has a parameter. You can usually know that by looking at the help of the structure (*e.g.* `?Process1Model`), else, you can still look at the field names of the structure like so `fieldnames(Process1Model)`. - -Note that the user only declares the models, not the way the models are coupled because `PlantSimEngine.jl` deals with that automatically. - -Now the example above returns some warnings saying we need to initialize some variables: `var1` and `var2`. `PlantSimEngine.jl` automatically computes which variables should be initialized based on the inputs and outputs of all models, considering their hard or soft-coupling. - -For example, `Process1Model` requires the following variables as inputs: - -```@example usepkg -inputs(Process1Model(2.0)) -``` - -And `Process2Model` requires the following variables: - -```@example usepkg -inputs(Process2Model()) -``` - -We see that `var1` is needed as inputs of both models, but we also see that `var3` is an output of `Process2Model`: - -```@example usepkg -outputs(Process2Model()) -``` - -So considering those two models, we only need `var1` and `var2` to be initialized, as `var3` is computed. This is why we recommend [`to_initialize`](@ref) instead of [`inputs`](@ref), because it returns only the variables that need to be initialized, considering that some inputs are duplicated between models, and some are computed by other models (they are outputs of a model): - -```@example usepkg -m = ModelMapping( - Process1Model(2.0), - Process2Model(), - Process3Model(), - variables_check=false # Just so we don't have the warning printed out -) - -to_initialize(m) -``` - -The most straightforward way of initializing a model list is by giving the initializations to the `status` keyword argument during instantiation: - -```@example usepkg -m = ModelMapping( - Process1Model(2.0), - Process2Model(), - Process3Model(), - status = (var1=15.0, var2=0.3) -) -nothing # hide -``` - -Our component models structure is now fully parameterized and initialized for a simulation! - -Let's simulate it: - -```@example usepkg -using PlantMeteo -meteo = Atmosphere(T = 22.0, Wind = 0.8333, P = 101.325, Rh = 0.4490995) - -run!(m, meteo) - -m[:var5] -``` - - -## Soft coupled models - -All following models (`Process4Model` to `Process7Model`) do not call explicitly other models when running, but some outputs of the models are used as inputs of other models. This is called a soft-dependency in PlantSimEngine. - -Let's make a new model list including the soft-coupled models: - -```@example usepkg -m = ModelMapping( - Process1Model(2.0), - Process2Model(), - Process3Model(), - Process4Model(), - Process5Model(), - Process6Model(), - Process7Model(), -) -nothing # hide -``` - -With this list of models, we only need to initialize `var0`, that is an input of `Process4Model` and `Process7Model`: - -```@example usepkg -to_initialize(m) -``` - -We can initialize it like so: - -```@example usepkg -m = ModelMapping( - Process1Model(2.0), - Process2Model(), - Process3Model(), - Process4Model(), - Process5Model(), - Process6Model(), - Process7Model(), - status = (var0=15.0,) -) -nothing # hide -``` - -Let's simulate it: - -```@example usepkg -using PlantMeteo -meteo = Atmosphere(T = 22.0, Wind = 0.8333, P = 101.325, Rh = 0.4490995) - -run!(m, meteo) - -status(m) -``` - -## Simulation order - -When calling `run!`, the models are run in the right order using a dependency graph that is computed automatically based on the hard and soft dependencies of the models following a simple set of rules: - -1. Independent models are run first. A model is independent if it can be run alone, or only using initializations. It is not dependent on any other model. -2. From their children dependencies: - 1. Hard dependencies are always run before soft dependencies. Inner hard dependency graphs are considered as a whole, *i.e.* as a single soft dependency. - 2. Soft dependencies are then run sequentially. If a soft dependency has several parent nodes (*i.e.* its inputs are computed by several models), it is run only if all its parent nodes have been run already. In practice, when we visit a node that has one of its parent that did not run already, we stop the visit of this branch. The node will eventually be visited from the branch of the last parent that was run. diff --git a/docs/src/model_execution.md b/docs/src/model_execution.md index 3d1c575f3..862b5a2f7 100644 --- a/docs/src/model_execution.md +++ b/docs/src/model_execution.md @@ -1,219 +1,404 @@ -# Model execution +# Model Execution -## Simulation order +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. -`PlantSimEngine.jl` uses the [`ModelMapping`](@ref) to automatically compute a dependency graph between the models and run the simulation in the correct order. When running a simulation with [`run!`](@ref), the models are then executed following this simple set of rules: +The public configuration surface is: -1. Independent models are run first. A model is independent if it can be run independently from other models, only using initializations (or nothing). -2. Then, models that have a dependency on other models are run. The first ones are the ones that depend on an independent model. Then the ones that are children of the second ones, and then their children ... until no children are found anymore. There are two types of children models (*i.e.* dependencies): hard and soft dependencies: - 1. Hard dependencies are always run before soft dependencies. A hard dependency is a model that is directly called by another model. It is declared as such by its parent that lists its hard-dependencies as `dep`. See [this example](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/3d91bb053ddbd087d38dcffcedd33a9db35a0fcc/examples/dummy.jl#L39) that shows `Process2Model` defining a hard dependency on any model that simulates `process1`. - 2. Soft dependencies are then run sequentially. A model has a soft dependency on another model if one or more of its inputs is computed by another model. If a soft dependency has several parent nodes (*e.g.* two different models compute two inputs of the model), it is run only if all its parent nodes have been run already. In practice, when we visit a node that has one of its parent that did not run already, we stop the visit of this branch. The node will eventually be visited from the branch of the last parent that was run. +```julia +CompositeModel +Object +ModelSpec +AppliesTo +Inputs +Calls +Updates +TimeStep +Environment +``` + +Scenarios start from `CompositeModel` and model applications. + +## Model Kernels And Applications + +A model kernel is still an ordinary PlantSimEngine model: + +- `inputs_(model)` declares required status variables; +- `outputs_(model)` declares variables the model computes; +- `meteo_inputs_(model)` declares environment variables it reads; +- `meteo_outputs_(model)` declares mutable environment variables it writes; +- `dep(model)` may declare model-author defaults; +- `run!(model, models, status, meteo, constants, extra)` contains the model + equations. -## Multi-rate model configuration (experimental) - -For multiscale simulations, model usage is configured in the mapping through `ModelSpec` transforms: - -- `TimeStepModel(...)`: sets model execution clock. -- `InputBindings(...)`: sets producer, source variable, optional source scale, and policy for each consumer input. -- `MeteoBindings(...)`: sets weather aggregation rules at the model clock for meteo variables. -- `MeteoWindow(...)`: sets weather row selection strategy (`RollingWindow()` or `CalendarWindow(...)`). -- `OutputRouting(...)`: sets whether an output is canonical (`:canonical`) or stream-only (`:stream_only`). -- `ScopeModel(...)`: partitions producer streams by scope (`:global`, `:plant`, `:scene`, `:self`) for multi-entity simulations. +The composite-model/object layer does not change that kernel contract. It adds a +scenario-specific application around the kernel: -For a compact overview of all model traits and precedence rules, see [Model traits](model_traits.md). +```julia +ModelSpec(LeafEnergyBalance(); name=:leaf_energy) |> + AppliesTo(Many(kind=:plant, scale=:Leaf)) |> + Inputs(...) |> + Calls(...) |> + TimeStep(Dates.Hour(1)) |> + Environment(provider=:canopy) +``` + +`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. -If users do not provide `MeteoBindings(...)` or `MeteoWindow(...)`, -the runtime can infer defaults from model traits: -- `timespec(::Type{<:MyModel})` -- `output_policy(::Type{<:MyModel})` -- `timestep_hint(::Type{<:MyModel})` -- `meteo_hint(::Type{<:MyModel})` +## Compilation Before Runtime -For timestep specifically, runtime is meteo-first (see decision flow below): `timestep_hint` -is used for compatibility validation (and user guidance), not to auto-assign model clocks. +Before the timestep loop, PlantSimEngine compiles the model into concrete +runtime carriers: -If users do not provide `InputBindings(...)`, runtime infers same-name bindings: -- first from a unique producer at the same scale; -- otherwise from a unique producer at another scale; -- if no producer exists, input stays unresolved (so initialization/forced values can be used); -- if multiple producers are possible, runtime errors and asks for explicit `InputBindings(...)`. +1. `AppliesTo(...)` selectors are resolved to stable object ids. +2. `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. `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. -For inferred bindings, default policy is resolved as: -- producer `output_policy` for the source output when defined; -- otherwise `HoldLast()`. +Selectors are not resolved in the hot loop. Runtime execution uses the +compiled indexes and carriers. -`output_policy` is a default hint, applied only when an output stream is actually read -by another model input (or output export). Unused outputs do not trigger integration/reduction work. +Useful inspection helpers: -Explicit mapping policies still have priority (`InputBindings(..., policy=...)`) and can -complement trait defaults by defining additional bindings with different policies. +```julia +explain_applications(model) +explain_bindings(model) +explain_calls(model) +explain_environment_bindings(model) +explain_schedule(model) +explain_execution_plan(model) +explain_writers(model) +``` -For timestep hints: -- `timestep_hint.required` is a hard compatibility constraint when runtime uses meteo-derived timestep. -- `timestep_hint.preferred` is informational only (it does not set runtime timestep by itself). -- Explicit `TimeStepModel(...)` always takes precedence. +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. -For meteo hints: -- return `(; bindings=..., window=...)` where `bindings` matches `MeteoBindings(...)` - and `window` matches `MeteoWindow(...)`. -- Explicit `MeteoBindings(...)` / `MeteoWindow(...)` always take precedence. +## Soft Dependencies With Inputs -Inspection helpers: -- `resolved_model_specs(mapping)` returns resolved specs after inference/validation. -- `explain_model_specs(mapping_or_sim)` prints a compact summary (`timestep`, - `input_bindings`, `meteo_bindings`, `meteo_window`) for each model process. +Soft dependencies are value dependencies. A consumer model reads a variable +produced by another model through `Inputs(...)`. -Policy parameterization: -- `Integrate()` defaults to `SumReducer()`; you can pass another reducer, e.g. `Integrate(MeanReducer())` or `Integrate(vals -> maximum(vals) - minimum(vals))`. -- `Aggregate()` defaults to `MeanReducer()`; you can pass reducers such as `Aggregate(MaxReducer())`. -- Difference between `Integrate` and `Aggregate`: with the same reducer they are runtime-equivalent. - In practice, only defaults and naming intent differ (`Integrate` for accumulation, `Aggregate` for summary statistics). -- `Interpolate()` defaults to `mode=:linear, extrapolation=:linear`; use `Interpolate(; mode=:hold, extrapolation=:hold)` for hold behavior. -- The same reducer objects are reused by meteo sampling (`MeteoBindings`) and by windowed policies (`Integrate`, `Aggregate`). -- Custom reducers/callables can accept either `(values)` or `(values, durations_seconds)`. -- For flux-to-amount conversions, use `Integrate(PlantMeteo.DurationSumReducer())` - (equivalent to `sum(values .* durations_seconds)`), instead of hardcoding a fixed factor. +```julia +ModelSpec(SceneLAI(ground_area); name=:scene_lai) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :leaf_areas => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:leaf_state, + var=:leaf_area, + ), + ) +``` -`TimeStepModel(...)` accepts either step counts (`Real`), `ClockSpec`, or fixed `Dates` periods -(for example `Dates.Hour(1)`, `Dates.Day(1)`). Fixed periods are converted internally using -the meteo base timestep duration. +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. -### Timestep decision flow +If an input is not explicitly declared with `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=...`. -When meteo is provided, `duration` is mandatory for each row (or the simulation errors). +Use `PreviousTimeStep(:x) => selector` when a feedback dependency should read +the previous sample instead of creating a same-timestep scheduling edge. -Runtime picks each model effective clock with this order: +## Hard Calls With Calls -1. If `ModelSpec` has `TimeStepModel(...)`, use it. -2. Else if `timespec(model)` is non-default, use it. -3. Else use meteo base timestep (`duration`) for that model. +Hard dependencies are manual calls. Use `Calls(...)` when a parent model must +control the call stack, for example during an iterative energy-balance solve. -Then runtime applies constraints: +```julia +ModelSpec(SceneEnergyBalance(); name=:scene_energy) |> + AppliesTo(One(scale=:Scene)) |> + Calls( + :leaf_energy => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:energy_balance, + ), + :soil => One( + kind=:soil, + scale=:Soil, + within=SceneScope(), + application=:soil_water, + ), + ) |> + TimeStep(Dates.Hour(1)) +``` -1. If the model clock is meteo-derived (rule 3), `timestep_hint.required` is validated: - - fixed required period: meteo timestep must match exactly; - - required range: meteo timestep must be inside the range. -2. `timestep_hint.preferred` never overrides the clock when timestep is unset. -3. Meteo aggregation/integration is applied only when effective model timestep is coarser than meteo timestep. +Inside `run!`, the parent can execute every resolved target directly. The +return value is always vector-like: `One` returns one element, `OptionalOne` +returns zero or one, and `Many` returns zero or more. -Practical consequences: +```julia +soil_targets = run_call!(extra, :soil; publish=true) +soil_status = only(soil_targets).status +``` -- Unset `TimeStepModel` + required includes meteo + preferred is coarser: - model still runs at meteo timestep. -- Explicit coarser `TimeStepModel(Dates.Hour(2))` with hourly meteo: - model runs every 2 hours and receives aggregated meteo over that window. -- Unset `TimeStepModel` + required excludes meteo: - runtime errors with an actionable compatibility message. - -Developer note on period conversion: -- Runtime time is indexed on a 1-based timeline (`t = 1, 2, 3, ...`). -- `TimeStepModel(Dates.Day(1))` is converted to a clock step count using: - `dt = day_seconds / meteo_step_seconds`. -- For hourly meteo (`duration = Dates.Hour(1)`), this gives `dt = 24` and the default phase is `1`, - so the model runs at `t = 1, 25, 49, ...`. -- This is equivalent to `ClockSpec(24.0, 1.0)`. -- If you need runs at `t = 24, 48, 72, ...`, set an explicit phase with `ClockSpec(24.0, 0.0)`. - -Typical pipeline form: +For finer-grained iterative control, retrieve targets without executing them +and decide when to publish the accepted state: ```julia -ModelSpec(MyModel()) |> -TimeStepModel(ClockSpec(24.0, 1.0)) |> -MeteoWindow(CalendarWindow(:day; anchor=:current_period, week_start=1, completeness=:strict)) |> -MeteoBindings(; T=MeanWeighted()) |> -InputBindings(; x=(process=:producer, var=:y, policy=HoldLast())) |> -OutputRouting(; z=:stream_only) +function PlantSimEngine.run!(model::SceneEnergyBalance, models, status, meteo, + constants, extra) + for target in call_targets(extra, :leaf_energy) + run_call!(target; meteo=trial_meteo(model, status)) + end + + for target in call_targets(extra, :leaf_energy) + run_call!(target; meteo=accepted_meteo(model, status), publish=true) + end + + return nothing +end ``` -### Calendar-aligned meteo windows +`run_call!` defaults to `publish=false`. Trial calls mutate target statuses but +do not publish temporal samples or scatter mutable environment outputs. Call +`run_call!(target; publish=true)` once for the accepted state. -`MeteoWindow(...)` controls how rows are selected before reducers are applied: -- `RollingWindow()` (default): trailing window based on `dt` (for example "last 24 steps"). -- `CalendarWindow(period; anchor, week_start, completeness)`: -: `period` in `:day`, `:week`, `:month` -: `anchor` in `:current_period`, `:previous_complete_period` -: `week_start` in `1:7` (1 = Monday) -: `completeness` in `:allow_partial`, `:strict` +Applications selected only by `Calls(...)` are marked manual-call-only in +`explain_schedule(model)` and are skipped by the root `run!(model)` loop. -`CalendarWindow(:day; anchor=:current_period, ...)` guarantees that a model running inside a day sees -aggregates over that civil day (including later timesteps from that day when available). +## Duplicate Writers With Updates -### Hold-last coupling (default policy) +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: ```julia -mapping = ModelMapping( - :Leaf => ( - ModelSpec(LeafSourceModel()) |> TimeStepModel(1.0), - ModelSpec(LeafConsumerModel()) |> - TimeStepModel(ClockSpec(2.0, 1.0)) |> - InputBindings(; C=(process=:leafsource, var=:S)), - ), -) +ModelSpec(CarbonAllocation(); name=:carbon_allocation) |> + AppliesTo(Many(scale=:Leaf)) + +ModelSpec(LeafPruning(); name=:leaf_pruning) |> + AppliesTo(Many(scale=:Leaf)) |> + Updates(:leaf_biomass; after=:carbon_allocation) ``` -### Daily integration from hourly stream +This keeps ordinary duplicate outputs as errors while allowing cases such as +allocation followed by pruning. `explain_writers(model)` reports writer +groups and the `Updates(...)` declarations that validate them. +The `after` value is the canonical application identifier shown by +`explain_applications(model)`, not the process name. + +## Multirate Execution + +Use `TimeStep(...)` with `Dates.Period` values for model application clocks: ```julia -mapping = ModelMapping( - :Leaf => ( - ModelSpec(HourlyAssimModel()) |> TimeStepModel(1.0), - ), - :Plant => ( - ModelSpec(DailyCarbonOfferModel()) |> - TimeStepModel(ClockSpec(24.0, 1.0)) |> - InputBindings(; A=(process=:hourlyassim, var=:A, scale=:Leaf, policy=Integrate())), - ), -) +ModelSpec(HourlyLeafAssimilation(); name=:leaf_assim) |> + AppliesTo(Many(scale=:Leaf)) |> + TimeStep(Dates.Hour(1)) + +ModelSpec(DailyPlantAllocation(); name=:allocation) |> + AppliesTo(Many(scale=:Plant)) |> + Inputs( + :leaf_assimilation => Many( + scale=:Leaf, + within=Subtree(), + application=:leaf_assim, + var=:A, + policy=Integrate(), + window=Dates.Day(1), + ), + ) |> + TimeStep(Dates.Day(1)) ``` -### Interpolate slow producer to fast consumer +Clock precedence is: + +1. explicit `TimeStep(...)` on the `ModelSpec`; +2. non-default `timespec(model)` trait; +3. the model environment base step. + +`timestep_hint(model)` is a compatibility constraint and explanation hint. It +does not silently choose a clock. If a model uses the environment base step and +that step violates `timestep_hint.required`, model compilation errors. + +Temporal input policy precedence is: + +1. explicit selector policy, such as `policy=Integrate()`; +2. producer `output_policy(model)` for that output; +3. `HoldLast()`. + +Supported policies are: + +- `HoldLast()`: use the latest producer sample; +- `Interpolate()`: interpolate or extrapolate from producer samples; +- `Integrate()`: reduce values over a window, defaulting to `SumReducer()`; +- `Aggregate()`: reduce values over a window, defaulting to `MeanReducer()`. + +`Integrate(...)` and `Aggregate(...)` accept reducer objects or callables that +take either `(values)` or `(values, durations_seconds)`. +For duration-aware reducers, each producer value is held until the next +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. + +## Environment Sampling + +`Environment(...)` chooses a provider and optional source-variable remapping: ```julia -mapping = ModelMapping( - :Leaf => ( - ModelSpec(SlowSourceModel()) |> TimeStepModel(ClockSpec(2.0, 1.0)), - ModelSpec(FastConsumerModel()) |> - TimeStepModel(1.0) |> - InputBindings(; X=(process=:slowsource, var=:X, policy=Interpolate())), - ), -) +ModelSpec(CO2Probe(); name=:co2_probe) |> + AppliesTo(Many(scale=:Leaf)) |> + Environment(provider=:canopy, sources=(CO2=:Ca,)) ``` -When the `ModelMapping` declares multirate configuration, the runtime resolves inputs from producer temporal streams according to these policies. -Meteo rows are also sampled at each model clock. By default, meteo variables are aggregated from -the finest weather step (for example `T` and `Rh` as weighted means, `Tmin/Tmax`, and radiation -quantity aliases such as `Ri_SW_q` in MJ m-2). You can override these rules with `MeteoBindings(...)` -on each `ModelSpec`. +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: -### Current limitations +- `meteo_inputs_(model)` says what the model reads; +- `meteo_outputs_(model)` says what the model writes back; +- `Environment(; sources=...)` maps model-facing names to backend names; +- geometry and position are used by spatial backends when available; +- object-to-environment links are cached and refreshed when objects move. -- Multi-rate MTG runs currently execute sequentially. Passing `executor=ThreadedEx()` or `executor=DistributedEx()` falls back to sequential execution with a warning. -- Sub-step execution is currently unsupported: model timesteps shorter than the meteo base step (for example `TimeStepModel(Dates.Minute(30))` with hourly meteo) raise an error. +Model-level `meteo_hint(...)` can provide default source bindings and +aggregation rules. Scenario-level `Environment(...)` keeps precedence for +source names, while explicit sampling policy on `Inputs(...)` controls +model-to-model temporal values. -## Multi-rate output export (experimental) +## Running And Outputs -You can export selected variables at a requested rate from temporal streams: +Run a model with: ```julia -req = OutputRequest(:Leaf, :carbon_assimilation; - name=:A_daily, - process=:toyassim, +sim = run!(model; steps=30) +``` + +The returned `Simulation` contains the mutated model, compiled bindings, +environment bindings, execution plan, and retained temporal output streams. + +By default, model runs retain no user output streams. Pass `outputs=:all` to +retain every published stream, or pass `OutputRequest` values to retain only +selected outputs and required temporal dependency streams: + +```julia +request = OutputRequest( + Many(scale=:Leaf), + :A; + name=:leaf_assimilation_daily, + application=:leaf_assimilation, policy=Integrate(), - clock=ClockSpec(24.0, 1.0) + clock=Dates.Day(1), ) -run!(sim, meteo; tracked_outputs=[req], executor=SequentialEx()) -exported = collect_outputs(sim; sink=DataFrame) +sim = run!(model; steps=72, outputs=request) +collect_outputs(sim, :leaf_assimilation_daily; sink=nothing) +explain_output_retention(sim) ``` -`tracked_outputs` accepts `OutputRequest` values for these resampled exports. -You can also return them directly from `run!`: +When several applications publish the same process and variable, use +`application=:application_name` in the request. This selects the named +application directly and can also request an explicitly named +`:stream_only` publisher. + +`outputs=:none` retains no user output streams. Histories required by temporal +dependencies are still maintained with bounded retention. + +`run!(model; ...)` always starts a fresh result timeline. Continue an existing +simulation without resetting its step index, environment position, multirate +phase, or temporal histories with: ```julia -out_status, exported = run!( - sim, - meteo; - tracked_outputs=[req], - return_requested_outputs=true, +continue!(sim; steps=24) +step!(sim) +current_step(sim) +``` + +Temporal dependency streams that are not explicitly requested retain only the +history required by their input policy. `HoldLast` keeps the latest sample, +`Integrate` and `Aggregate` keep their input window, and `Interpolate` and +`PreviousTimeStep` keep sufficient recent source samples. Requested streams +retain complete histories for post-run export. `explain_output_retention(sim)` +reports `retention_steps` for bounded dependency-only streams and `nothing` +for full-history streams. + +## Lifecycle Changes + +CompositeModel objects may be added, removed, reparented, moved, or have their geometry +updated between or during timesteps: + +```julia +register_object!(model, Object(:new_leaf; scale=:Leaf); parent=:plant_1) +leaf_status = add_organ!( + parent_node, + model, + :+, + :Leaf, + 3; + index=4, + attributes=(area=0.01,), + initial_status=(biomass=0.0,), ) +remove_object!(model, :old_leaf) +reparent_object!(model, :leaf_3, :plant_2) +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` with the model's MTG policy, registers the model +object, and invalidates the affected bindings. `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. The next `run!` or `continue!` timestep refreshes the necessary +caches. + +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(extra)` to obtain +the live model. Objects created during a kernel call do not recursively execute +inside that call. Structural targets, value carriers, call targets, writer +validation, schedules, and output-request matches are refreshed at the next +timestep boundary. Geometry-only mutations refresh affected environment +bindings at that boundary; already published streams remain available for +removed objects. + +## Historical API Translation + +The historical mapping runtime has been removed. Translate old code as follows: + +- `ModelMapping(...)` -> `CompositeModel(...)` plus object-local `ModelSpec(...)` + applications; +- `MultiScaleModel(...)` -> `Inputs(...)`; +- `InputBindings(...)` -> selector fields inside `Inputs(...)`; +- `MeteoBindings(...)` / `MeteoWindow(...)` -> `Environment(...)`, + `meteo_hint(...)`, and model/application clocks; +- `TimeStepModel(...)` -> `TimeStep(...)`. + +See [Migrating To The CompositeModel/Object API](migration_composite_model.md) for worked +translation patterns. diff --git a/docs/src/model_traits.md b/docs/src/model_traits.md index 6c9633451..aada72a1d 100644 --- a/docs/src/model_traits.md +++ b/docs/src/model_traits.md @@ -1,154 +1,84 @@ -# Model traits +# Model Traits -This page centralizes the model-level traits that can be defined in `PlantSimEngine`. -It complements: +Model traits describe intrinsic model behavior. Scenario-specific coupling +belongs in `ModelSpec` through `AppliesTo`, `Inputs`, `Calls`, `TimeStep`, +`Environment`, `OutputRouting`, and `Updates`. -- [Model execution](model_execution.md) for runtime behavior, -- [Parallelization](step_by_step/parallelization.md) for execution over objects/time-steps. +## Variables -## Trait inventory for models - -### `timespec(::Type{<:MyModel})` - -Defines the default execution clock of a model. - -Default: +Implement `inputs_(model)` and `outputs_(model)` with default values: ```julia -PlantSimEngine.timespec(::Type{<:AbstractModel}) = ClockSpec(1.0, 0.0) +PlantSimEngine.inputs_(::MyModel) = (leaf_area=0.0,) +PlantSimEngine.outputs_(::MyModel) = (assimilation=0.0,) ``` -Use it when your model has a natural native clock (for example daily by default). +The declarations are used for status initialization, dependency inference, +validation, and type construction. -### `output_policy(::Type{<:MyModel})` +## Manual Dependencies -Defines per-output default schedule policy for produced streams. - -Default: +Implement `dep(model)` only when the model directly calls another process from +inside its own `run!` method: ```julia -PlantSimEngine.output_policy(::Type{<:AbstractModel}) = NamedTuple() +PlantSimEngine.dep(::EnergyBalance) = ( + photosynthesis=AbstractPhotosynthesisModel, +) ``` -Behavior: +The scenario binds the dependency with `Calls(...)`. The parent executes all +resolved targets with `run_call!(extra, :photosynthesis)`, which always returns +a vector-like collection. Use `call_targets` plus `run_call!(target)` when the +parent needs selective trials and accepted publication. -- unspecified outputs fall back to `HoldLast()`; -- used by runtime when resolving cross-clock reads; -- used as default policy for inferred `InputBindings(...)` when users do not provide explicit bindings; -- hint-only and lazy: policy is applied only for outputs that are actually consumed/exported. - Declaring a policy for an unused output does not trigger integration work. +## Timing -Example: +`timespec(model)` declares the model's default clock. The default is +`ClockSpec(1.0, 0.0)`. ```julia -PlantSimEngine.output_policy(::Type{<:MyModel}) = ( - carbon_assimilation=Integrate(), - leaf_temperature=Aggregate(MeanReducer()), -) +PlantSimEngine.timespec(::Type{<:DailyGrowth}) = ClockSpec(Dates.Day(1)) ``` -Users can always override or complement this trait at mapping level: +`output_policy(model)` declares the default temporal policy per output: ```julia -ModelSpec(MyConsumerModel()) |> -InputBindings( - ; - carbon_assimilation=(process=:myproducer, var=:carbon_assimilation, policy=HoldLast()), # override trait default - carbon_assimilation_max=(process=:myproducer, var=:carbon_assimilation, policy=Aggregate(MaxReducer())), # complement with extra derived input +PlantSimEngine.output_policy(::Type{<:MyModel}) = ( + assimilation=Integrate(), + leaf_temperature=Aggregate(MeanReducer()), ) ``` -### `timestep_hint(::Type{<:MyModel})` +Unspecified outputs use `HoldLast()`. A scenario can select another clock with +`TimeStep(...)` and another input policy in `Inputs(...)`. -Optional compatibility hint when `TimeStepModel(...)` is not provided. +`timestep_hint(model)` can declare required or preferred timestep constraints. +`meteo_hint(model)` can provide default environment sampling configuration. -Default: +## Environment Variables -```julia -PlantSimEngine.timestep_hint(::Type{<:AbstractModel}) = nothing -``` - -Supported forms include: - -- fixed period: `Dates.Hour(1)`; -- range: `(Dates.Minute(30), Dates.Hour(2))`; -- named tuple: `(; required=..., preferred=...)`. - -`required` is enforced when runtime uses meteo-derived timestep. -`preferred` is informational only. - -### `meteo_hint(::Type{<:MyModel})` - -Optional inference trait for weather sampling configuration. - -Default: +Use `meteo_inputs_(model)` for variables sampled from the active environment +backend: ```julia -PlantSimEngine.meteo_hint(::Type{<:AbstractModel}) = nothing -``` - -Expected value: - -```julia -(; bindings=..., window=...) +PlantSimEngine.meteo_inputs_(::LeafEnergyBalance) = ( + T=0.0, + Rh=0.0, + Wind=0.0, + Ri_PAR_f=0.0, + CO2=400.0, +) ``` -Where: - -- `bindings` is compatible with `MeteoBindings(...)`, -- `window` is compatible with `MeteoWindow(...)`. - -### `TimeStepDependencyTrait(::Type{<:MyModel})` -### `ObjectDependencyTrait(::Type{<:MyModel})` - -Parallelization traits (single-scale runtime): - -- `TimeStepDependencyTrait`: depends or not on other timesteps; -- `ObjectDependencyTrait`: depends or not on other objects. - -Defaults are conservative (`dependent`) and can be overridden when safe. - -## Precedence rules - -Runtime precedence is intentionally explicit: - -1. Input policy: - explicit `InputBindings(..., policy=...)` > inferred from producer `output_policy` > `HoldLast()`. -1. Timestep: - `TimeStepModel(...)` > `timespec(model)` when non-default > meteo base step. -1. Meteo sampling: - explicit `MeteoBindings(...)`/`MeteoWindow(...)` > `meteo_hint(...)` > runtime defaults. - -## Is everything documented? - -For model-level traits, the documented set is now: - -- `timespec`, -- `output_policy`, -- `timestep_hint`, -- `meteo_hint`, -- `TimeStepDependencyTrait`, -- `ObjectDependencyTrait`. - -Outside model traits, `PlantSimEngine` also exposes data-format traits such as `DataFormat` for input containers (see [Input types](working_with_data/inputs.md)). - -## Naming conventions and API consistency - -Current API uses two naming styles on purpose: - -- snake_case for trait/query functions (`timespec`, `output_policy`, `timestep_hint`, `meteo_hint`); -- CamelCase for `ModelSpec` pipeline transforms (`TimeStepModel`, `InputBindings`, `MeteoBindings`, `MeteoWindow`, `OutputRouting`, `ScopeModel`). - -This distinction reflects role: +Use `meteo_outputs_(model)` for variables scattered back into a mutable +microclimate backend. Such variables are normally also declared in +`outputs_(model)` because the current runtime reads their values from status. -- snake_case: "what the model declares"; -- CamelCase: "what the mapping config applies". +## Precedence -For future unification, a non-breaking path would be: +Scenario configuration has precedence over model defaults: -1. keep existing names as stable API, -1. avoid plain snake_case aliases that would collide with existing getter names - (`input_bindings`, `meteo_bindings`, `output_routing`, `model_scope`), -1. if needed, add explicit config-oriented aliases with distinct names - (for example `*_config` forms) and keep current constructors, -1. evaluate deprecations only after one full release cycle and user feedback. +1. `Inputs(...)` policy, then producer `output_policy`, then `HoldLast()`. +2. `TimeStep(...)`, then `timespec(model)`, then the environment base step. +3. `Environment(...)`, then `meteo_hint(model)`, then backend defaults. diff --git a/docs/src/multirate/advanced_configuration.md b/docs/src/multirate/advanced_configuration.md deleted file mode 100644 index 28197648b..000000000 --- a/docs/src/multirate/advanced_configuration.md +++ /dev/null @@ -1,208 +0,0 @@ -# Advanced multi-rate configuration - -This page collects the multi-rate features that were intentionally kept in the -background on the first two pages: - -- [Introduction to multi-rate execution](introduction.md) explains the core - scheduling rules; -- [Step-by-step multi-rate tutorial](multirate_tutorial.md) shows a complete - hourly/daily/weekly MTG example with minimal configuration; -- this page covers the explicit configuration tools you reach for when defaults - are no longer enough. - -The goal here is not to build another full simulation from scratch. Instead, the -objective is to explain when and why you should add more explicit multi-rate -declarations to a mapping. - -## 1. When the defaults are enough - -PlantSimEngine tries to keep simple mappings concise: - -- if a model does not declare `TimeStepModel(...)`, it follows the meteo - cadence; -- if an input has a unique producer, `InputBindings(...)` can often be omitted; -- if a model consumes common `Atmosphere` variables at a coarser cadence, - PlantMeteo default transforms can often replace explicit `MeteoBindings(...)`; -- if an exported variable has a unique canonical publisher, `OutputRequest(...)` - can often omit `process=`. - -The sections below focus on the cases where that implicit behavior becomes too -ambiguous or too limiting. - -## 2. Explicit model-to-model bindings with `InputBindings(...)` - -The tutorial pages rely on unique-producer inference plus `output_policy(...)` -declared on the source models. That is the simplest setup, but it stops being -enough as soon as several candidate producers exist or when you want to override -the default resampling rule. - -Use explicit `InputBindings(...)` when: - -- several models can produce the same input variable; -- the same process exists at several reachable scales; -- the source variable has a different name than the consumer input; -- the producer default policy is not the policy you want for this particular - connection. - -For example, a daily plant model may need to say explicitly that it consumes the -hourly leaf assimilation stream from the `:Leaf` scale and integrates it over -the day: - -```julia -plant_daily_spec = ModelSpec(TutorialPlantDailyModel()) |> - TimeStepModel(ClockSpec(24.0, 0.0)) |> - InputBindings(; - leaf_assim_h=( - process=:tutorialleafhourly, - scale=:Leaf, - var=:leaf_assim_h, - policy=Integrate(), - ), - ) -``` - -This is more verbose than inference, but the resulting mapping is also more -explicit: anyone reading it can see exactly where the data comes from and how it -is reduced. - -## 3. Explicit meteorological aggregation with `MeteoBindings(...)` - -For common `Atmosphere` variables, PlantSimEngine delegates weather sampling to -PlantMeteo, and PlantMeteo already defines default transforms. In practice, this -means you often do not need `MeteoBindings(...)` for variables such as `T`, -`Rh`, or aliases like `Ri_SW_q`. - -Add explicit `MeteoBindings(...)` when: - -- you want a non-default reducer; -- the target variable should come from a differently named source variable; -- the variable is not covered by PlantMeteo defaults; -- you want the mapping itself to document the intended weather aggregation rule. - -For example, this daily model makes the defaults explicit for temperature and -shortwave radiation energy: - -```julia -plant_daily_spec = ModelSpec(TutorialPlantDailyModel()) |> - TimeStepModel(ClockSpec(24.0, 0.0)) |> - MeteoBindings( - ; - T=MeanWeighted(), - Ri_SW_q=(source=:Ri_SW_f, reducer=RadiationEnergy()), - ) -``` - -And this variant shows a more genuinely custom rule: - -```julia -plant_daily_spec = ModelSpec(TutorialPlantDailyModel()) |> - TimeStepModel(ClockSpec(24.0, 0.0)) |> - MeteoBindings( - ; - T=(source=:T, reducer=MaxReducer()), - rad_peak=(source=:Ri_SW_f, reducer=MaxReducer()), - ) -``` - -The important point is that `MeteoBindings(...)` is not only about reducing -weather from fast to slow. It is also a way to state the semantics of that -reduction explicitly. - -## 4. Calendar-aligned windows with `MeteoWindow(...)` - -By default, coarser meteo sampling uses rolling windows that follow the model -clock. That is often sufficient, but some models are tied to civil periods such -as "the current day" or "the current week". - -In those cases, use `MeteoWindow(...)` to replace the default trailing window -with a calendar-aligned one: - -```julia -plant_daily_spec = ModelSpec(TutorialPlantDailyModel()) |> - TimeStepModel(ClockSpec(24.0, 0.0)) |> - MeteoWindow( - CalendarWindow( - :day; - anchor=:current_period, - week_start=1, - completeness=:strict, - ), - ) -``` - -This becomes important when a daily or weekly model should aggregate over civil -days or weeks rather than over "the last 24 hours" or "the last 168 hours". - -## 5. Exporting streams with `OutputRequest(...)` - -The second tutorial page uses `OutputRequest(...)` to materialize clean -hourly/daily/weekly tables from the simulation streams. The simple form works -well when the requested variable has a unique canonical publisher: - -```julia -req_plant_daily = OutputRequest(:Plant, :plant_assim_d; - name=:plant_assim_daily, - clock=ClockSpec(24.0, 0.0), -) -``` - -More complex mappings often need more explicit requests. In particular, add -`process=` when several models can publish the same variable, and add `policy=` -when you need a specific export-time resampling behavior: - -```julia -req_daily_energy = OutputRequest(:Leaf, :leaf_assim_h; - name=:leaf_energy_daily, - process=:tutorialleafhourly, - policy=Integrate(), - clock=ClockSpec(24.0, 0.0), -) - -req_hourly_hold = OutputRequest(:Plant, :plant_assim_d; - name=:plant_assim_hold_hourly, - process=:tutorialplantdaily, - policy=HoldLast(), - clock=ClockSpec(1.0, 0.0), -) -``` - -So `OutputRequest(...)` is not just a way to rename a column. It is also a -declaration of which stream you want, at which cadence, and with which -resampling policy. - -## 6. Inspect resolved configuration - -When a mapping mixes inferred bindings, explicit bindings, custom meteo -aggregation, scopes, and export requests, it becomes difficult to reason about -the final resolved configuration by inspection alone. - -That is where `explain_model_specs(...)` and `resolved_model_specs(...)` become -useful: - -```julia -explain_model_specs(mapping) - -resolved = resolved_model_specs(mapping) -resolved[:Plant] -``` - -These helpers let you confirm: - -- the effective timestep of each model; -- the resolved input bindings; -- the resolved meteo bindings; -- the active meteo window. - -In practice, this is often the fastest way to debug a multi-rate mapping before -running a larger simulation. - -## 7. How to choose between the three pages - -Use the pages in this order: - -1. start with [Introduction to multi-rate execution](introduction.md) if you - want to understand the scheduling rules; -2. continue with [Step-by-step multi-rate tutorial](multirate_tutorial.md) for - a complete but compact MTG example; -3. come back to this page when you need explicit bindings, explicit meteo - aggregation, custom export requests, scopes, or debugging helpers. diff --git a/docs/src/multirate/introduction.md b/docs/src/multirate/introduction.md deleted file mode 100644 index a0556ceae..000000000 --- a/docs/src/multirate/introduction.md +++ /dev/null @@ -1,200 +0,0 @@ -# Introduction to multi-rate execution - -This page introduces the basic ideas behind multi-rate execution in -PlantSimEngine. - -The goal here is not to build a realistic plant model. Instead, the objective is -to make the mechanics of multi-rate execution easy to see: - -- how PlantSimEngine decides when a model runs; -- how values are transferred from a faster model to a slower one; -- how meteorological inputs are reduced over a coarse time window. - -Once those ideas are clear, the -[step-by-step multi-rate tutorial](multirate_tutorial.md) shows how to assemble -a more complete hourly/daily/weekly MTG simulation. - -## Decision flow quick examples - -Before building a larger example, it helps to establish two important rules: - -1. if a model does not declare an explicit timestep, it follows the meteo cadence; -2. if a model is forced to run more coarsely than its inputs, then explicit input - and meteo binding policies determine how information is aggregated. - -### Simple example with implicit meteo cadence - -Model may define a trait calles `timestep_hint` that describes the acceptable and preferred cadences for that model. However, that trait is purely descriptive: it does not force the model to run at any particular rate. If you want to force a model to run at a specific cadence, you must declare an explicit `TimeStepModel(...)` in the mapping. Otherwise, the model will simply run whenever the meteo cadence allows it to, and the `timestep_hint` can be used for validation or explanation but does not silently reschedule the model. - -Let's define a tiny model that simply counts how many times it ran, then feed it -three 30-minute weather rows: - -```@example multirate_timestep_flow -using PlantSimEngine -using PlantMeteo -using MultiScaleTreeGraph -using DataFrames -using Dates - -mtg = Node(NodeMTG("/", :Scene, 1, 0)) -plant = Node(mtg, NodeMTG("+", :Plant, 1, 1)) -internode = Node(plant, NodeMTG("/", :Internode, 1, 2)) -Node(internode, NodeMTG("+", :Leaf, 1, 2)) - -PlantSimEngine.@process "tutorialmeteodriven" verbose=false -struct TutorialMeteoDrivenModel <: AbstractTutorialmeteodrivenModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::TutorialMeteoDrivenModel) = NamedTuple() -PlantSimEngine.outputs_(::TutorialMeteoDrivenModel) = (count=-Inf,) -function PlantSimEngine.run!(m::TutorialMeteoDrivenModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.count = float(m.n[]) -end -PlantSimEngine.timestep_hint(::Type{<:TutorialMeteoDrivenModel}) = (; required=(Minute(30), Hour(2)), preferred=Hour(1)) -``` - -This model is designed to run between every 30 minutes and every 2 hours, with a preferred cadence of 1 hour. Let's make a mapping with the model but without an explicit `TimeStepModel(...)`: - -```@example multirate_timestep_flow -mapping = ModelMapping(:Leaf => (TutorialMeteoDrivenModel(Ref(0)),)) -``` - -Let's define a 30-minute weather table with three rows: - -```@example multirate_timestep_flow -meteo_30min = Weather([ - Atmosphere(date=DateTime(2025, 6, 12, 12, 0, 0), duration=Minute(30), T=20.0, Wind=1.0, Rh=0.6), - Atmosphere(date=DateTime(2025, 6, 12, 12, 30, 0), duration=Minute(30), T=21.0, Wind=1.0, Rh=0.6), - Atmosphere(date=DateTime(2025, 6, 12, 13, 0, 0), duration=Minute(30), T=22.0, Wind=1.0, Rh=0.6), -]) -``` - -Now we run the model and check how many times it ran over those three 30-minute rows: - -```@example multirate_timestep_flow -out_meteo_driven = run!( - mtg, - mapping, - meteo_30min; - executor=SequentialEx(), - tracked_outputs=Dict(:Leaf => (:count,)), -) -out_meteo_driven[:Leaf][end] -``` - -The last value for `:count` is `3.0`, showing the model ran on all three 30-minute meteo rows, -even though `preferred=Hour(1)`. - -That is the key point: without `TimeStepModel`, the model still follows the -incoming meteo table. The preferred timestep can be used for validation or for -explanation, but it does not silently reschedule the model. - -### Using `TimeStepModel` to manage multi-rate coupling - -The second example shows the complementary case. Here we explicitly ask one model -to run hourly, even though its source data arrives every 30 minutes. Once we do -that, PlantSimEngine needs instructions for two distinct questions: - -- how to combine the 30-minute source output into an hourly model input; -- how to combine 30-minute meteorological rows into the hourly meteo seen by the - coarse model. - -That is what `InputBindings(...)` and `MeteoBindings(...)` are for. -In this tiny example, we keep the mapping simple by declaring the default -reduction policy on the source model itself with `output_policy(...)`. Since `A` -has a unique producer on the same scale, PlantSimEngine can infer the source -automatically and reuse that policy. - -Let's define a simple 30-minute source model that produces a constant value `A=1.0` every time it runs, and declare that its output should be integrated when consumed by a slower model: - -```@example multirate_timestep_flow -PlantSimEngine.@process "tutorialhalfhoursource" verbose=false -struct TutorialHalfHourSourceModel <: AbstractTutorialhalfhoursourceModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::TutorialHalfHourSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::TutorialHalfHourSourceModel) = (A=-Inf,) -function PlantSimEngine.run!(m::TutorialHalfHourSourceModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.A = 1.0 # umol m-2 s-1 -end -PlantSimEngine.output_policy(::Type{<:TutorialHalfHourSourceModel}) = (; A=Integrate(DurationSumReducer())) -``` - -Note that `output_policy(...)` says that when a slower model consumes `A`, the default is to integrate it over the coarser time window, using the duration of each source row as weights. - -Now we define a simple hourly model that consumes `A` and also reads hourly mean temperature from the meteo: - -```@example multirate_timestep_flow -PlantSimEngine.@process "tutorialhourlyintegrator" verbose=false -struct TutorialHourlyIntegratorModel <: AbstractTutorialhourlyintegratorModel end -PlantSimEngine.inputs_(::TutorialHourlyIntegratorModel) = (A=-Inf,) -PlantSimEngine.outputs_(::TutorialHourlyIntegratorModel) = (A_hourly=-Inf, T_hourly=-Inf,) -function PlantSimEngine.run!(::TutorialHourlyIntegratorModel, models, status, meteo, constants=nothing, extra=nothing) - status.A_hourly = status.A - status.T_hourly = meteo.T -end -``` - -!!! note - We make two deliberate simplifications here to keep the example compact: - 1. The hourly model simply copies the integrated `A` value into a new variable called `A_hourly`. This is bad design in a real model because it creates unnecessary variables and makes the data flow less transparent. In a real model, you would typically consume `A` directly and let the integrated value be called `A` as well. However, here we create a separate variable to make it obvious that the hourly model is receiving an aggregated version of the original `A`. - 2. We don't define an `output_policy(...)` for the hourly model, because it is not consumed by any slower model. Usually, developers are encouraged to define `output_policy(...)` for all models, but here we omit it for the hourly model to keep the example compact. - -Now we can declare a mapping that says the hourly model runs every hour, even though its source data arrives every 30 minutes. We also declare how to reduce the meteorological inputs to match the hourly cadence: - -```@example multirate_timestep_flow -mapping_coarse = ModelMapping( - :Leaf => ( - ModelSpec(TutorialHalfHourSourceModel(Ref(0))), - ModelSpec(TutorialHourlyIntegratorModel()) |> - TimeStepModel(Hour(1)) |> - MeteoBindings(; T=MeanWeighted()), - ), -) -``` - -Setting the `TimeStepModel(Hour(1))` forces the second model to run hourly. Since it consumes `A` from the first model, PlantSimEngine looks at the source model's `output_policy(...)` and sees that it should integrate `A` over the hour using the duration of each 30-minute row as weights. - -!!! note - If we had omitted `TimeStepModel(Hour(1))`, the hourly model would have simply run on each 30-minute row, and the `output_policy(...)` on the source model would not have been triggered. The hourly model would have received the original 30-minute `A` values instead of an hourly aggregate. This illustrates the key point: `TimeStepModel(...)` is what triggers the multi-rate coupling and the use of reduction policies. - -In our example, the hourly model does not declare a `timestep_hint`, so it can run at any cadence. By declaring `TimeStepModel(Hour(1))`, we explicitly force it to run hourly, which means it will receive aggregated inputs and meteo. - -!!! note - Because our hourly model does not declare a `timestep_hint`, it is flexible and can run at any cadence. However, if we had declared a `timestep_hint` that did not include hourly as an acceptable cadence, then PlantSimEngine would have raised an error when we tried to force it to run hourly. Consequently, it is usually a good practice to declare a `timestep_hint` when writing a model, because it helps to ensure that the model is used in a way that is consistent with its design and intended use. - -Let's now run the simulation: - -```@example multirate_timestep_flow -meteo_30min_4 = Weather([ - Atmosphere(date=DateTime(2025, 6, 12, 12, 0, 0), duration=Minute(30), T=20.0, Wind=1.0, Rh=0.6), - Atmosphere(date=DateTime(2025, 6, 12, 12, 30, 0), duration=Minute(30), T=22.0, Wind=1.0, Rh=0.6), - Atmosphere(date=DateTime(2025, 6, 12, 13, 0, 0), duration=Minute(30), T=24.0, Wind=1.0, Rh=0.6), - Atmosphere(date=DateTime(2025, 6, 12, 13, 30, 0), duration=Minute(30), T=26.0, Wind=1.0, Rh=0.6), -]) - -out_coarse = run!( - mtg, - mapping_coarse, - meteo_30min_4; - executor=SequentialEx(), - tracked_outputs=Dict(:Leaf => (:A_hourly, :T_hourly)), -) -out_coarse[:Leaf][end] -``` - -The final timestep outputs are `3600.0` for `A_hourly` and `23.0` for `T_hourly`: hourly integrated assimilation -(`sum(A .* duration_seconds)` over two 30-minute rows) and hourly mean temperature over the coarse window. - -So this example already captures the core multi-rate idea: the fast model still -runs at the fine cadence, while the coarse model sees explicitly reduced inputs -and meteorology at its own cadence. - -From here, there are two natural next steps: - -- [Step-by-step multi-rate tutorial](multirate_tutorial.md) for a more complete - MTG example; -- [Advanced multi-rate configuration](advanced_configuration.md) for explicit - bindings, meteo windows, export requests, scopes, and debugging helpers. diff --git a/docs/src/multirate/multirate_tutorial.md b/docs/src/multirate/multirate_tutorial.md deleted file mode 100644 index 49c76dd58..000000000 --- a/docs/src/multirate/multirate_tutorial.md +++ /dev/null @@ -1,429 +0,0 @@ -# Step-by-step multi-rate tutorial (hourly + daily + weekly) - -This page builds a more complete MTG simulation that mixes three model rates: -- hourly at `Leaf`, -- daily at `Plant`, -- weekly at `Plant`. - -It runs for one week and exports clean series at each rate. - -If you want the conceptual overview first, start with -[Introduction to multi-rate execution](introduction.md). This page assumes you -already understand the two basic ideas introduced there: - -1. without `TimeStepModel(...)`, a model follows the meteo cadence; -2. once a model is forced to run more coarsely than its inputs, PlantSimEngine - must reduce both model outputs and meteorological inputs to match that slower - cadence. - -The goal of this second page is to put those ideas into a more contextualized -MTG example, where we mix hourly, daily, and weekly models in the same -simulation and export clean time series at each rate. - -## 1. Setup and example data - -This tutorial is more contextualized than the previous one. To keep the mechanics -readable, we work with a minimal MTG containing only one plant and one leaf. That -way the exported tables stay small enough to inspect directly. - -We also reuse package example assets instead of inventing new input files. In particular, we use a weather file available from the package examples: - -- `examples/meteo_day.csv` for weather. - -We start by importing the packages we need and by creating a very small MTG with -only four nodes: a `Scene`, a `Plant`, one `Internode`, and one `Leaf`. - -```@example multirate_tutorial -using PlantSimEngine -using PlantMeteo -using MultiScaleTreeGraph -using DataFrames -using CSV -using Dates - -# Minimal plant: Scene -> Plant -> Internode -> Leaf -mtg = Node(NodeMTG("/", :Scene, 1, 0)) -plant = Node(mtg, NodeMTG("+", :Plant, 1, 1)) -internode = Node(plant, NodeMTG("/", :Internode, 1, 2)) -Node(internode, NodeMTG("+", :Leaf, 1, 2)) -``` - -Next, we point to the bundled weather file and confirm that it exists: - -```@example multirate_tutorial -meteo_path = joinpath(pkgdir(PlantSimEngine), "examples", "meteo_day.csv") -@assert isfile(meteo_path) -``` - -The weather file bundled with the package is daily. Since this tutorial is about -mixing several rates, we first convert one week of daily weather into an -hourly weather table. The values are simply repeated within each day, which is -perfectly fine here because the purpose is to illustrate scheduling and data flow -rather than to create a realistic forcing dataset. - -The first step is to read the file and keep only one week of rows: - -```@example multirate_tutorial -daily_df = CSV.read(meteo_path, DataFrame, header=18) -week_df = first(daily_df, 7) -``` - -We then expand each day into 24 hourly `Atmosphere` rows: - -```@example multirate_tutorial -hourly_rows = Atmosphere[] -for row in eachrow(week_df) - for h in 0:23 - push!(hourly_rows, - Atmosphere( - date=DateTime(row.date) + Hour(h), - duration=Hour(1), - T=row.T, - Wind=row.Wind, - P=row.P, - Rh=row.Rh, - Ri_PAR_f=row.Ri_PAR_f, - Ri_SW_f=row.Ri_SW_f, - ) - ) - end -end -``` - -Finally, we wrap those rows into a `Weather` object, which is what `run!` expects: - -```@example multirate_tutorial -meteo_hourly = Weather(hourly_rows) -meteo_hourly[1:3] # show the first 3 rows of the hourly weather table -``` - -## 2. Defining simple models - -Next we define three deliberately simple models: - -- an hourly `Leaf` model that turns incoming radiation into an hourly - assimilation value; -- a daily `Plant` model that sums hourly leaf assimilation over a day and also - consumes daily meteorological aggregates; -- a weekly `Plant` model that sums daily plant assimilation into one weekly - value. - -These models are intentionally minimal. Their role is to make the rate changes -and aggregation policies obvious. - -We begin with the hourly leaf model. It reads hourly meteorological radiation and -produces an hourly assimilation value: - -```@example multirate_tutorial -PlantSimEngine.@process "tutorialleafhourly" verbose=false -struct TutorialLeafHourlyModel <: AbstractTutorialleafhourlyModel end -PlantSimEngine.inputs_(::TutorialLeafHourlyModel) = NamedTuple() -PlantSimEngine.outputs_(::TutorialLeafHourlyModel) = (leaf_assim_h=0.0,) -function PlantSimEngine.run!(::TutorialLeafHourlyModel, models, status, meteo, constants=nothing, extra=nothing) - status.leaf_assim_h = 0.004 * meteo.Ri_PAR_f -end -PlantSimEngine.output_policy(::Type{<:TutorialLeafHourlyModel}) = (; leaf_assim_h=Integrate()) -``` - -The `output_policy(...)` declaration matters for multi-rate use: it says that -when a slower model consumes `leaf_assim_h`, the natural default is to integrate -it over the coarser time window. - -Now we define the daily plant model. It receives leaf assimilation values, -aggregates them over a day, and also reads daily reduced meteo variables: - -```@example multirate_tutorial -PlantSimEngine.@process "tutorialplantdaily" verbose=false -struct TutorialPlantDailyModel <: AbstractTutorialplantdailyModel end -PlantSimEngine.inputs_(::TutorialPlantDailyModel) = (leaf_assim_h=[0.0],) -PlantSimEngine.outputs_(::TutorialPlantDailyModel) = (plant_assim_d=0.0, rad_sw_day=0.0, T=0.0) -function PlantSimEngine.run!(::TutorialPlantDailyModel, models, status, meteo, constants=nothing, extra=nothing) - status.plant_assim_d = sum(status.leaf_assim_h) - status.rad_sw_day = meteo.Ri_SW_q - status.T = meteo.T -end -PlantSimEngine.output_policy(::Type{<:TutorialPlantDailyModel}) = (; plant_assim_d=Integrate()) -``` - -Again, `output_policy(...)` is used so that a coarser consumer can infer the -appropriate default behavior for `plant_assim_d`. - -Finally, we define the weekly plant model. It simply sums the daily plant -assimilation values over one week: - -```@example multirate_tutorial -PlantSimEngine.@process "tutorialplantweekly" verbose=false -struct TutorialPlantWeeklyModel <: AbstractTutorialplantweeklyModel end -PlantSimEngine.inputs_(::TutorialPlantWeeklyModel) = (plant_assim_d=[0.0],) -PlantSimEngine.outputs_(::TutorialPlantWeeklyModel) = (plant_assim_w=0.0,) -function PlantSimEngine.run!(::TutorialPlantWeeklyModel, models, status, meteo, constants=nothing, extra=nothing) - status.plant_assim_w = sum(status.plant_assim_d) -end -``` - -At this point nothing is multi-rate yet. We have simply defined three processes -whose intended cadences are hourly, daily, and weekly. The multi-rate behavior is -declared in the mapping. - -## 3. Configure multi-rate mapping - -This is the heart of the tutorial. The mapping below does three things at once: - -1. it assigns each model to a scale; -2. it declares the timestep at which each model should run; -3. it defines how values move between rates and between scales. - -Two pieces are especially important here: - -- `TimeStepModel(...)` states the model cadence; -- PlantMeteo reduces meteorological inputs automatically when a model runs more - coarsely than the weather data. - -For model-to-model bindings, this tutorial relies on automatic source inference -plus `output_policy(...)` on the source models. That keeps the main example -compact while still exercising multi-rate input aggregation. - -We start by defining the three clocks used in the simulation. These are the -cadences that will later be assigned to the three models: - -```@example multirate_tutorial -hourly = 1.0 -daily = ClockSpec(24.0, 0.0) -weekly = ClockSpec(168.0, 0.0) -``` - -The leaf model is straightforward: it runs hourly and is scoped to the current -plant. There is no multiscale mapping or meteo reduction to declare here, because -the leaf model is the fastest model in this example and directly consumes the -hourly weather rows: - -```@example multirate_tutorial -leaf_spec = TutorialLeafHourlyModel() |> ModelSpec |> TimeStepModel(hourly) -``` - -So at this point we have simply said: "run the leaf model every hour" - -The daily plant model is where multi-rate coupling becomes visible. It: - -- receives `leaf_assim_h` from the `:Leaf` scale through `MultiScaleModel(...)`; -- runs daily; -- receives daily meteorological aggregates from the hourly weather automatically. - -The important idea is that this model does not read the raw hourly values -directly. Instead, it sees a daily view of those data: - -- `leaf_assim_h` is integrated over the daily window because of the source - model's `output_policy(...)`; -- `T` is turned into a daily mean by the default PlantMeteo sampling rules; -- `Ri_SW_q` is computed by integrating `Ri_SW_f` over the day. - -```@example multirate_tutorial -plant_daily_spec = - TutorialPlantDailyModel() |> - ModelSpec |> - MultiScaleModel([:leaf_assim_h => :Leaf]) |> - TimeStepModel(daily) -``` - -This block is the first place where the "multi-rate" behavior is really visible: -one model consumes fine-grained biological outputs and fine-grained meteorology, -but only after both have been reduced to the model's own daily cadence. - -The weekly plant model is simpler again: it only needs to run weekly and receive -the daily plant output automatically. Since `plant_assim_d` has a unique producer -and already declares its own `output_policy(...)`, we do not need to add any -explicit binding here: - -```@example multirate_tutorial -plant_weekly_spec = - TutorialPlantWeeklyModel() |> - ModelSpec |> - TimeStepModel(weekly) -``` - -So this weekly model effectively says: "take the daily plant assimilation stream, -reduce it again to my weekly cadence, and run once per week." - -We can now assemble the full mapping: - -```@example multirate_tutorial -mapping = ModelMapping( - :Leaf => (leaf_spec,), - :Plant => (plant_daily_spec, plant_weekly_spec), -) -``` - -Reading this mapping from top to bottom: - -- the `Leaf` model runs hourly and produces `leaf_assim_h`; -- the daily `Plant` model receives leaf values from the `Leaf` scale through - `MultiScaleModel([:leaf_assim_h => :Leaf])`, then integrates them over a day; -- that same daily model also receives daily meteorological summaries through the - default PlantMeteo sampling rules; -- the weekly `Plant` model integrates the daily plant output into one weekly - value. - -!!! note - In this tutorial, explicit `InputBindings(...)` are omitted because each - input has a unique, inferable producer and the default reduction policy is - declared on the source model with `output_policy(...)`. - - In more complex mappings, you should use explicit `InputBindings(process=..., scale=..., var=..., policy=...)` when: - - several models can produce the same input variable; - - the same process exists at several reachable scales; - - the source variable has a different name than the consumer input; - - you want to override the producer's default policy for a specific mapping. - -!!! note - `MeteoBindings(...)` is also omitted on purpose in the main example. - PlantSimEngine delegates weather sampling to PlantMeteo, which already - defines default transformations for common `Atmosphere` variables such as - `T`, `Rh`, and radiation aliases like `Ri_SW_q`. - - Add explicit `MeteoBindings(...)` when: - - you want a non-default reducer; - - the model expects a target variable with a different source name; - - the variable is not covered by PlantMeteo default transforms; - - you want the mapping to state the weather aggregation rule explicitly. - - ```@example multirate_tutorial - # The same daily model, with weather aggregation rules written explicitly. - plant_daily_spec_explicit_meteo = ModelSpec(TutorialPlantDailyModel()) |> - MultiScaleModel([:leaf_assim_h => :Leaf]) |> - TimeStepModel(daily) |> - MeteoBindings( - ; - T=MeanWeighted(), - Ri_SW_q=(source=:Ri_SW_f, reducer=RadiationEnergy()), - ) - ``` - -## 4. Run and export hourly/daily/weekly series - -Now we run the simulation and request three exported series. This is a good place -to distinguish two related outputs returned by `run!`: - -- the regular simulation outputs (`out_status` below), which still contain the - model outputs tracked during the run; -- the explicitly requested exported series (`exported` below), which are the - clean hourly/daily/weekly tables we asked PlantSimEngine to materialize. - -We use `OutputRequest(...)` to say which variable we want and on which clock. -Here again we keep the example minimal: `process=` is omitted because each -requested output has a unique canonical publisher. - -We first declare the export requests. One request keeps the hourly leaf series, -another exports the daily plant series, and the last one exports the weekly plant -series. - -The point of these requests is to obtain three clean tables that each live at a -single rate, instead of having to reconstruct those time series manually from -the full simulation outputs: - -```@example multirate_tutorial -req_leaf_hourly = OutputRequest(:Leaf, :leaf_assim_h; - name=:leaf_assim_hourly, -) - -req_plant_daily = OutputRequest(:Plant, :plant_assim_d; - name=:plant_assim_daily, - clock=daily, -) - -req_plant_weekly = OutputRequest(:Plant, :plant_assim_w; - name=:plant_assim_weekly, - clock=weekly, -) -``` - -Then we run the simulation and ask PlantSimEngine to return both the regular -simulation outputs and the explicitly requested exported series: - -- `out_status` contains the regular tracked outputs of the simulation; -- `exported` contains the resampled, per-request tables defined above. - -```@example multirate_tutorial -out_status, exported = run!( - mtg, - mapping, - meteo_hourly; - executor=SequentialEx(), - tracked_outputs=[req_leaf_hourly, req_plant_daily, req_plant_weekly], - return_requested_outputs=true, -) -``` - -Finally, we extract the exported tables we want to inspect. At this point we are -no longer dealing with abstract stream definitions: we now have actual `DataFrame` -objects containing hourly, daily, and weekly series. - -```@example multirate_tutorial -leaf_hourly_df = exported[:leaf_assim_hourly] -plant_daily_df = exported[:plant_assim_daily] -plant_weekly_df = exported[:plant_assim_weekly] -``` - -The exported tables already have the cadence we asked for, so they are much -easier to inspect than a single mixed output table. - -We can start with a few basic checks on the number of rows. These checks are a -simple way to confirm that the export clocks did what we expected: - -```@example multirate_tutorial -@show nrow(leaf_hourly_df) # 168 (1 leaf x 168 hours) -@show nrow(plant_daily_df) # 7 (1 plant x 7 days) -@show nrow(plant_weekly_df) # 1 (1 plant x 1 week) -``` - -The hourly table has one row per hour, the daily table one row per day, and the -weekly table one row for the whole run. - -To compare the hourly and daily outputs directly, we group the hourly series by -day and sum it manually. This lets us check that the daily plant model really did -receive the integrated hourly leaf assimilation: - -```@example multirate_tutorial -leaf_hourly_df.day = repeat(1:7, inner=24) -leaf_hourly_sum = combine(groupby(leaf_hourly_df, :day), :value => sum => :leaf_assim_h_sum) -``` - -Those row counts match the intended design of the example: one hourly series for -seven days, one daily series for seven days, and one weekly aggregate for the -whole run. - -We can also manually recompute the daily sums from the hourly exported series and -compare them with the daily model output: - -```@example multirate_tutorial -plant_daily_df -``` - -This confirms that the daily assimilation values correspond to the sum of the -hourly leaf assimilation collected over each day. - -The regular outputs returned by `run!` are still available as well, and can be -converted to `DataFrame`s in the usual way. This is useful when you want both: - -- clean resampled exports for analysis; -- the usual simulation outputs for debugging or broader inspection. - -```@example multirate_tutorial -outs = convert_outputs(out_status, DataFrame) -outs[:Plant][1:3,:] -``` - -## 5. Where to go next - -This page keeps the main walkthrough focused on a complete but still compact -example. Once that example is clear, the next step is usually to learn the -explicit configuration tools that become useful in larger mappings: - -- `InputBindings(...)` when inference is ambiguous or too implicit; -- `MeteoBindings(...)` when PlantMeteo defaults are not enough; -- `MeteoWindow(...)` for calendar-aligned aggregation; -- `OutputRequest(...)` when you want explicit export-time clocks and policies; -- `ScopeModel(...)`, `explain_model_specs(...)`, and `resolved_model_specs(...)` - for larger and harder-to-debug MTGs. - -Those topics are grouped in -[Advanced multi-rate configuration](advanced_configuration.md). diff --git a/docs/src/multiscale/multiscale.md b/docs/src/multiscale/multiscale.md deleted file mode 100644 index 062520811..000000000 --- a/docs/src/multiscale/multiscale.md +++ /dev/null @@ -1,255 +0,0 @@ -# Multi-scale variable mapping - -The previous page showed how to convert a single-scale simulation to multi-scale. - -This page provides another example showcasing the nuances in variable mapping, with a more complex fully multiscale version of a prior simulation. The models will all be taken form the [examples folder](https://github.com/VirtualPlantLab/PlantSimEngine.jl/tree/main/examples). - -```@contents -Pages = ["multiscale.md"] -Depth = 3 -``` - -## Starting with a single-model mapping - -Let's import the `PlantSimEngine` package and all the example models we will use in this tutorial: - -```@example usepkg -using PlantSimEngine -using PlantSimEngine.Examples # Import some example models -``` - -Let's create a simple mapping with only one initial model, the carbon assimilation process ToyAssimModel, which will operate on leaves. -It resembles the ToyAssimGrowth model used in the single-scale simulation [Model switching](@ref) subsection. - -Our mapping between scale and model is therefore: - -```@example usepkg -mapping = ModelMapping(:Leaf => ToyAssimModel()) -``` - -Just like in single-scale simulations, we can call `to_initialize` to check whether variables need to be initialised. It will this time index by scale: - -```@example usepkg -to_initialize(mapping) -``` - -In this example, the ToyAssimModel needs `:aPPFD` and `:soil_water_content` as inputs, which aren't initialised in our mapping. - -The initialization values for the variables can be passed along via a [`Status`](@ref) object: - -```@example usepkg -mapping = ModelMapping( - :Leaf => ( - ToyAssimModel(), - Status(aPPFD=1300.0, soil_water_content=0.5), - ), -) -``` - -If we call [`to_initialize`](@ref) on this new mapping, it returns an empty dictionary, meaning the mapping is valid, and we can start the simulation: - -```@example usepkg -to_initialize(mapping) -``` - -## Multiscale mapping between models and scales - -The `soil_water_content` variable was provided via the mapping. No model affects it, so it is constant in the above example. We could instead provide a model that computes it based on weather data, and/or a more realistic physical process. - -It also makes sense to have that model operate at a different scale than the :Leaf scale. There is a dummy soil model called `ToySoilModel` in the examples folder. Let's put it at a new :Soil scale level. - -ToyAssimModel is now makes use of the `soil_water_content` variable from the `:Soil` scale, instead of at its own scale via the `Status` initialization. We therefore need to map `soil_water_content` from the :Soil to the :Leaf scale by wrapping `ToyAssimModel` in a `MultiScaleModel`: - -```@example usepkg -mapping = ModelMapping( - :Soil => ToySoilWaterModel(), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => :Soil => :soil_water_content,], - ), - Status(aPPFD=1300.0), - ), -); -nothing # hide -``` - -In this example, we map the `soil_water_content` variable at scale :Leaf to the `soil_water_content` variable at the `:Soil` scale. If the name of the variable is the same between both scales, we can omit the variable name at the origin scale, *e.g.* `[:soil_water_content => :Soil]`. - -The variable `aPPFD` is still provided in the `Status` type as a constant value. - -We can check again if the mapping is valid by calling [`to_initialize`](@ref): - -```@example usepkg -to_initialize(mapping) -``` - -Once again, `to_initialize` returns an empty dictionary, meaning the mapping is valid. - -## A more elaborate multiscale model mapping - -Let's now expand this mapping, to showcase other ways in which variables can be mapped from one scale to another. We'll keep the first two models, and add several more to simulate a couple of other processes within our plant. - -```@example usepkg -mapping = ModelMapping( - :Scene => ToyDegreeDaysCumulModel(), - :Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => :Scene, - ], - ), - Beer(0.6), - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - ), - :Internode => ( - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => :Scene,], - ), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(carbon_biomass=1.0), - ), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => :Soil, :aPPFD => :Plant], - ), - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => :Scene,], - ), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - Status(carbon_biomass=0.5), - ), - :Soil => ( - ToySoilWaterModel(), - ), -); -nothing # hide -``` - -This mapping might seem a little more daunting than previous examples, but several models should be recognizable in passing. In fact, you can consider this mapping to be an enhanced and more complex multi-scale version of a previous single-scale example, the coupling between photosynthesis model, a LAI model and a carbon biomass increment model, used in the [Model switching](@ref) subsection. - -```julia -models2 = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyAssimGrowthModel(); - status=(TT_cu=cumsum(meteo_day.TT),), -) -``` - -The multi-scale models simulate carbon capture via photosynthesis and carbon allocation for the plant organs' maintenance respiration and development. - -The LAI and photosynthesis models are the same as in the single-scale mapping example. The [`ToyDegreeDaysCumulModel`](@ref) provides the Cumulative Thermal Time to the plant. - -The newly introduced models have the following dynamic : - -Carbon allocation is determined (ToyCAllocationModel) for the different organs of the plant (`:Leaf` and `:Internode`) from the assimilation at the `:Leaf` scale (*i.e.* the offer) and their carbon demand (ToyCDemandModel). The `:Soil` scale is used to compute the soil water content (`ToySoilWaterModel`](@ref)), which is needed to calculate the assimilation at the `:Leaf` scale (ToyAssimModel). Also note that maintenance respiration at computed at the `:Leaf` and `:Internode` scales (ToyMaintenanceRespirationModel), and aggregated to compute the total maintenance respiration at the `:Plant` scale (ToyPlantRmModel). - -## Different possible variable mappings - -The above mapping showcases the different ways to define how the variables are mapped in a `MultiScaleModel` : - -```julia - mapped_variables=[:TT_cu => :Scene,], -``` - -- At the :Plant scale, the TT_cu variable is mapped as a scalar from the :Scene scale. There is only a single :Scene node in the MTG, and only a single "TT_cu" value per timestep for the simulation. - -```julia -:carbon_allocation => [:Leaf] -``` - -- On the other hand, we have `:carbon_allocation => [:Leaf]` at the plant scale for `ToyCAllocationModel`. The `carbon_assimilation` variable is mapped as a vector: there are multiple :Leaf nodes, but only one :Plant node, which aggregrates the value over every single leaf. This gives us a 'many-to-one' vector mapping, and in the [`run!`](@ref) functions for models at that scale `carbon_allocation` will be available in the `status` as a vector. - -```julia -:carbon_allocation => [:Leaf, :Internode] -``` - -- A third type of the mapping would be `:carbon_allocation => [:Leaf, :Internode]`, which provides values for a variable from several other scales simultaneously. In this case, the values are also available as a vector in the `carbon_assimilation` variable of the [`status`](@ref) inside the model, sorted in the same order as nodes are traversed in the graph. - -```julia -:Rm_organs => [:Leaf => :Rm, :Internode => :Rm] -``` - -- Finally, to map to a specific variable name at the target scale, *e.g.* `:Rm_organs => [:Leaf => :Rm, :Internode => :Rm]`. This syntax is useful when the variable name is different between scales, and we want to map to a specific variable name at the target scale. In this example, the variable `Rm_organs` at plant scale takes its values (is mapped) from the variable `Rm` at the `:Leaf` and `:Internode` scales. - -## Running a simulation - -Now that we have a valid mapping, we can run a simulation. Running a multiscale simulation requires a plant graph and the definition of the output variables we want dynamically for each scale. - -### Plant graph - -We can import an example multi-scale tree graph like so: - -```@example usepkg -mtg = import_mtg_example() -``` - -!!! note - You can use `import_mtg_example` only if you previously imported the `Examples` sub-module of PlantSimEngine, *i.e.* `using PlantSimEngine.Examples`. - -This graph has a root node that defines a scene, then a soil, and a plant with two internodes and two leaves. - -### Output variables - -For long simulations on plants with many organs, the output data can be very significant. It's possible to restrict the output variables that are tracked for the whole simulation to a subset of all the variables: - -```@example usepkg -outs = Dict( - :Scene => (:TT, :TT_cu,), - :Plant => (:aPPFD, :LAI), - :Leaf => (:carbon_assimilation, :carbon_demand, :carbon_allocation, :TT), - :Internode => (:carbon_allocation,), - :Soil => (:soil_water_content,), -) -``` - -This dictionary can be passed to the simulation via the optional `tracked_outputs` keyword argument to the [`run!`](@ref) function (see the next part). If no dictionary is provided, every variable will be tracked. - -These variables will be available in the output returned by [`run!`](@ref), with a value for each time step. The corresponding timestep and node in the MTG are also returned. - -### Meteorological data - -As for mono-scale models, we need to provide meteorological data to run a simulation. We can use the `PlantMeteo` package to generate some dummy data for two time steps: - -```@example usepkg -meteo = Weather( - [ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f = 200.0), - Atmosphere(T=25.0, Wind=0.5, Rh=0.8, Ri_PAR_f = 180.0) -] -) -``` - -### Simulation - -Let's make a simulation using the graph and outputs we just defined: - -```@example usepkg -outputs_sim = run!(mtg, mapping, meteo, tracked_outputs = outs); -nothing # hide -``` - -And that's it! We can now access the outputs for each scale as a dictionary of vectors of NamedTuple objects. - -Or as a `DataFrame` dictionary using the [`DataFrames`](https://dataframes.juliadata.org) package: - -```@example usepkg -using DataFrames -df_dict = convert_outputs(outputs_sim, DataFrame) -``` diff --git a/docs/src/multiscale/multiscale_considerations.md b/docs/src/multiscale/multiscale_considerations.md deleted file mode 100644 index 091cf32e6..000000000 --- a/docs/src/multiscale/multiscale_considerations.md +++ /dev/null @@ -1,163 +0,0 @@ -# Multi-scale considerations - -```@contents -Pages = ["multiscale_considerations.md"] -Depth = 3 -``` - -This page briefly details the subtle ways in which multi-scale simulations differ from prior single-scale simulations. The next few pages will showcase some of these subtleties with examples. - -Declaring and running a multi-scale simulation follows the same general workflow as the single-scale version, but multi-scale simulations do have some differences : - -- a simulation requires a Multi-scale Tree Graph (MTG) to run and operates on that graph -- when running, models are tied to a scale and only access local information -- models can run multiple times per timestep - -The simulation dependency graph will still be computed automatically and handle most couplings, meaning users don't need to specify the order of model execution once the extra code to declare the models is written. You will still need to declare hard dependencies, with extra considerations for multi-scale hard dependencies. - -Multi-scale simulations also tend to require more extra ad hoc models to prepare some variables for some models. - -## Related pages - -Other pages in the multiscale section describe : - -- How convert a single-scale ModelMapping to a multi-scale one: [Converting a single-scale simulation to multi-scale](@ref), -- A more complex multi-scale version of the single-scale simulation showcasing different variable mappings between scales: [Multi-scale variable mapping](@ref), -- A three-part tutorial describing how to build up a combination of models to simulate a growing toy plant: [Writing a multiscale simulation](@ref), -- Ways to handle situations where a variable ends up causing a cyclic dependency: [Avoiding cyclic dependencies](@ref), -- Multi-scale specific coupling considerations and subtleties:[Handling dependencies in a multiscale context](@ref) - -## Multi-scale tree graphs - -Functional-Structural Plant Models are often about simulating plant growth. A multi-scale simulation is implicitely expected to operate on a plant-like object, represented by a multi-scale tree graph. - -A multi-scale tree graph (MTG) object (see the [Multi-scale Tree Graphs](@ref) subsection for a quick description) is therefore required to run a multi-scale simulations. It can be a dummy MTG if the simulation doesn't actually affect it, but is nevertheless a required argument to the multi-scale [`run!`](@ref) function. - -All the multi-scale examples make use of the companion package [MultiScaleTreeGraph.jl](https://github.com/VEZY/MultiScaleTreeGraph.jl), which we therefore recommend for running your own multi-scale simulations. Visualizing a Multi-scale Tree Graph can be done using [PlantGeom](https://github.com/VEZY/PlantGeom.jl). - -!!! note - Multi-scale Tree Graphs make use of conflicting terminology with PlantSimEngine's concepts, which is discussed in [Scale/symbol terminology ambiguity](@ref). If you are new to those concepts, make sure to read that section and keep note of it. - -## Models run once per organ instance, not once per organ level - -Some models, like the ones we've seen in single-scale simulations, work on a very simple model of a whole plant. - -More fine-grained models can be tied to a specific plant organ. - -For instance, a model computing a leaf's surface area depending on its age would operate at the `:Leaf` scale, and be called **for every leaf** at every timestep. On the other hand, a model computing the plant's total leaf area only needs to be run once per timestep, and can be run at the `:Plant` scale. - -This is a major difference between a single-scale simulation and a multi-scale one. By default, any model in a single-scale simulation will only run **once** per timestep. However, in multi-scale, if a plant has several instances of an organ type -say it has a hundred leaves- then any model operating at the :Leaf scale will by default run one hundred times per timestep, unless it is explicitely controlled by another model (which can happen in hard dependency configurations). - -## Mappings - -When users define which models they use, PlantSimEngine cannot determine in advance which scale level they operate at. This is partly because the plant organs in an MTG do not have standardized names, and partly because some plant organs might not be part of the initial MTG, so parsing it isn't enough to infer what scales are used. - -The user therefore needs to indicate for a simulation's which models are related to which scale. - -A multi-scale mapping links models to the scale at which they operate, and is also implemented in a [`ModelMapping`](@ref), tying a scale, such as :Leaf to models operating at that scale, such as "LeafSurfaceAreaModel". - -Multi-scale models can be similar models to the ones found in earlier sections, or, if they need to make use of variables at other scales, may need to be wrapped as part of a [`MultiScaleModel`](@ref) object. Many models are not tied to a particular scale, which means those models can be reused at different scales or in single-scale simulations. - -## The simulation operates on an MTG - -Unlike in single-scale simulations, which make use of a [`Status`](@ref) object to store the current state of every variable in a simulation, multi-scale simulations operate on a per-organ basis. - -This means every organ instance has its own [`Status`](@ref), with scale-specific attributes. - -This has two **important** consequences in terms of running a simulation : - -- First, **any scale absent from the MTG will not be run**. If your MTG contains no leaves, then no model operating at the scale :Leaf will be able to run until a :Leaf organ is created and a node is added in the MTG. Otherwise, it has no MTG node to operate on. The only exceptions are hard dependency models which can be called from a different scale, since they can be called directly by a model on a node at a different existing scale, even if there is no node at their own scale. - -- Secondly, models only have access to **local** organ information. The [`status`](@ref) argument in the [`run!`](@ref) function only contains variables **at the model's scale**, unless variables from other scales are mapped via a [`MultiScaleModel`](@ref) wrapping. - -## The run! function's signature - -The [`run!`](@ref) function differs slightly from its single-scale version. The current structure (excluding a couple of advanced/deprecated kwargs) is the following: - -```julia -run!(mtg, mapping::ModelMapping, meteo, constants, extra; nsteps, tracked_outputs) -``` - -Instead of a just the [`ModelMapping`](@ref), it also takes an MTG as the first argument. The optional `meteo` and `constants` argument are identical to the single-scale version. The `extra` argument is now reserved and should not be used. A new `nsteps` keyword argument is available to restrict the simulation to a specified number of steps. - -## Multi-scale output data structure - -The output structure, like the mapping, is a Julia `Dict` structure indexed by the scale name. Values are a per-scale `Vector{NamedTuple}` which lists the requested variables for every node at that scale, for every timestep in the simulation. Timestep and Multiscale Tree Graph nodes are also added to the output data, as a `:timestep`and a `:node` entry. - -This dictionary structure makes the outputs as-is a little more verbose to inspect than in single-scale, but the general usage is similar, and it is both compact, and fast to convert to a `Dict{String, DataFrame}` which can make queries easier. - -!!! note - Some of the mapped variables -those that map from scalar to vector- will not be added to the outputs to save some memory and space since they are redundant. - -To illustrate, here's an example output from part 3 of the Toy plant tutorial, zeroing in on a variable at the :Root scale: [Fixing bugs in the plant simulation](@ref): - -```julia -julia> outs - -Dict{String, Vector} with 5 entries: - :Internode => @NamedTuple{timestep::Int64, node::Node{NodeMTG, Dict{Symbol, Any}}, carbon_root_creation_consumed::Float64, TT_cu::Float64, carbon_… - :Root => @NamedTuple{timestep::Int64, node::Node{NodeMTG, Dict{Symbol, Any}}, carbon_root_creation_consumed::Float64, water_absorbed::Float64… - :Scene => @NamedTuple{timestep::Int64, node::Node{NodeMTG, Dict{Symbol, Any}}, TT_cu::Float64, TT::Float64}[(timestep = 1, node = / 1: Scene… - :Plant => @NamedTuple{timestep::Int64, node::Node{NodeMTG, Dict{Symbol, Any}}, carbon_root_creation_consumed::Float64, carbon_stock::Float64, … - :Leaf => @NamedTuple{timestep::Int64, node::Node{NodeMTG, Dict{Symbol, Any}}, carbon_captured::Float64}[(timestep = 1, node = + 4: Leaf… - -julia> outs[:Root] -3257-element Vector{@NamedTuple{timestep::Int64, node::Node{NodeMTG, Dict{Symbol, Any}}, carbon_root_creation_consumed::Float64, water_absorbed::Float64, root_water_assimilation::Float64}}: - (timestep = 1, node = + 9: Root -└─ < 10: Root - └─ < 11: Root - └─ < 12: Root - └─ < 13: Root - └─ < 14: Root - └─ < 15: Root - └─ < 16: Root - └─ < 17: Root -, carbon_root_creation_consumed = 50.0, water_absorbed = 0.5, root_water_assimilation = 1.0) - ⋮ -``` - -Values are more complex to query than in a single-scale simulation since the indexing isn't straightforward to map to a timestep: - -```julia -julia> [Pair(outs[:Root][i][:timestep], outs[:Root][i][:carbon_root_creation_consumed]) for i in 1:length(outs[:Root])] -3257-element Vector{Pair{Int64, Float64}}: - 1 => 50.0 - 1 => 50.0 - 2 => 50.0 - 2 => 50.0 - 2 => 50.0 - ⋮ - 365 => 50.0 - 365 => 50.0 - 365 => 50.0 - 365 => 50.0 - 365 => 50.0 - 365 => 50.0 - 365 => 50.0 - 365 => 50.0 - 365 => 50.0 -``` - -Converting to a dictionary of DataFrame objects can make such queries easier to write. - -!!! warning - Currently, the `:node` entry only shallow copies nodes. The `:node` values at each scale for every timestep actually reflect the final state of the node, meaning attribute values may not correspond to the value at that timestep. You may need to output these values via a dedicated model to keep track of them properly. - Also note that there currently is no way of removing nodes. Nodes corresponding to organs considered to be pruned/dead/aborted are still present in the output data structure. - -Multi-scale simulations, especially for plants which have thousands of leaves, internodes, root branches, buds and fruits, may compute huge amounts of data. Just like in single-scale simulations, it is possible to keep only variables whose values you want to track for every timestep, and filter the rest out, using the `tracked_outputs` keyword argument for the [`run!`](@ref) function. - -Those tracked variables also need to be indexed by scale to avoid ambiguity: - -```julia -outs = ModelMapping( - :Scene => (:TT, :TT_cu,), - :Plant => (:aPPFD, :LAI), - :Leaf => (:carbon_assimilation, :carbon_demand, :carbon_allocation, :TT), - :Internode => (:carbon_allocation,), - :Soil => (:soil_water_content,), -) -``` - -## Coupling and multi-scale hard dependencies - -Multi-scale brings new types of coupling: mappings are part of the approach used to handle variables used by models at different scales. A model can also have a hard dependency on another model that operates at another scale. This multi-scale-specific complexity is discussed in [Handling dependencies in a multiscale context](@ref) diff --git a/docs/src/multiscale/multiscale_coupling.md b/docs/src/multiscale/multiscale_coupling.md deleted file mode 100644 index 15d75ec29..000000000 --- a/docs/src/multiscale/multiscale_coupling.md +++ /dev/null @@ -1,171 +0,0 @@ - -# Handling dependencies in a multiscale context - -```@contents -Pages = ["multiscale_coupling.md"] -Depth = 3 -``` - -## Scalar and vector variable mappings - -In the detailed example discussed previously [Multi-scale variable mapping](@ref), there were several instances of mapping a variable from one scale to another, which we'll briefly describe again to help transition to the next and more advanced subsection. Here's a relevant exerpt from the mapping : - -```julia -:Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => :Scene, - ], - ), - ... - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode] - ], - ), - ... - ), -``` - -For flexibility reasons, instead of explicitely linking most models from different scales together, one only declares which variables are meant to be taken from another scale (or more accurately, a model at a different scale outputting those variables). This keeps the convenience of switching models while making few changes to the mapping. - -However, PlantSimEngine cannot infer which scales have multiple instances, and which are single-instance, as the scale names are user-defined. - -In the above example, there is only one scene at the :Scene, and one plant at the :Plant scale, meaning the `TT_cu` variable mapped between the two has a one-to-one scalar-to-scalar correspondance. - -On the other hand, the `carbon_assimilation` variable is computed for **every** leaf, of which there could be hundreds, or thousands, giving a scalar-to-vector correspondance. The carbon assimilation model runs many times every timestep, whereas the carbon allocation model only runs once per timestep. There may be initially be only a single leaf, though, meaning PlantSimEngine cannot currently guess from the initial configuration that there might be multiple leaves created during the simulation. - -Hence the difference in mapping declaration : `TT_cu`is declared as a scalar correspondence : -```julia -:TT_cu => :Scene, -``` -whereas `carbon_assimilation` (and other variables) will be declared as a vector correspondence : -```julia -:carbon_assimilation => [:Leaf], -``` - -Note that there may be instances where you might wish to write your own model to aggregate a variable from a multi-instance scale. - -## Hard dependencies between models at different scale levels - -If a model requires some input variable that is computed at another scale, then providing the appropriate mapping for that variable will resolve name conflicts and enable that model to run with no further steps for the user or the modeler when the coupling is a 'soft dependency'. - -In the case of a hard dependency that operates **at the same scale as its parent**, declaring the hard dependency is exactly the same as in single-scale simulations and there are also no new extra steps on the user-side: - -- The parent model directly handles the call to its hard dependency model(s), meaning they are not explicitely managed by the top-level dependency graph. -- This means only the owning model of that dependency is visible in the graph, and its hard dependency nodes are internal. -- When the caller (or any downstream model that requires some variables from the hard dependency model) operates at the same scale, variables are easily accessible, and no mapping is required. - -On the other hand, modelers do need to bear in mind a couple of subtleties when developing models that possess hard dependencies that operate **at a different organ level from their parent**: - -If an model needs to be directly called by a parent but operates at a different scale/organ level, a modeler must declare hard dependencies with their respective organ level, similarly to the way the user provides a mapping. - -Conceptually : - -```julia - PlantSimEngine.dep(m::ParentModel) = ( - name_provided_in_the_mapping=AbstractHardDependencyModel => [:Organ_Name_1], -) -``` - -### An example from the toy plant simulation tutorial - -You can find an example of a hard dependency discussed in the [A multi-scale hard dependency appears](@ref) subsection of the third part of toy plant tutorial. - -### An example from XPalm.jl - -Here's a concrete example in [XPalm](https://github.com/PalmStudio/XPalm.jl), an oil palm model developed on top of PlantSimEngine. - Organs are produced at the phytomer scale, but need to run an age model and a biomass model at the reproductive organs' scales. - -```julia - PlantSimEngine.dep(m::ReproductiveOrganEmission) = ( - initiation_age=AbstractInitiation_AgeModel => [m.male_symbol, m.female_symbol], - final_potential_biomass=AbstractFinal_Potential_BiomassModel => [m.male_symbol, m.female_symbol], -) -``` - -The user-mapping includes the required models at specific organ levels. Here's the relevant portion of the mapping for the male reproductive organ : - -```julia -mapping = ModelMapping( - ... - :Male => - MultiScaleModel( - model=XPalm.InitiationAgeFromPlantAge(), - mapped_variables=[:plant_age => :Plant,], - ), - ... - XPalm.MaleFinalPotentialBiomass( - p.parameters[:male][:male_max_biomass], - p.parameters[:male][:age_mature_male], - p.parameters[:male][:fraction_biomass_first_male], - ), - ... -) -``` - -The model's constructor provides convenient default names for the scale corresponding to the reproductive organs. A user may override that if their naming schemes or MTG attributes differ. - -```julia -function ReproductiveOrganEmission(mtg::MultiScaleTreeGraph.Node; phytomer_symbol=:Phytomer, male_symbol=:Male, female_symbol=:Female) - ... -end -``` - -## Implementation details: accessing a hard dependency's variables from a different scale - -But how does a model M calling a hard dependency H provide H's variables when calling H's [`run!`](@ref) function ? The [`status`](@ref) argument the user provides M operates at M's organ level, so if used to call H's run! function any required variable for H will be missing. - -PlantSimEngine provides what are called Status Templates in the simulation graph. Each organ level has its own Status template listing the available variables at that scale. -So when a model M calls a hard dependency H's [`run!`](@ref) function, any required variables can be accessed through the status template of H's organ level. - -### Back to the XPalm example - -Using the same example in XPalm, the oil palm FSPM: - -```julia -# Note that the function's 'status' parameter does NOT contain the variables required by the hard dependencies as the calling model's organ level is "Phytomer", not :Male or "Female" - -function PlantSimEngine.run!(m::ReproductiveOrganEmission, models, status, meteo, constants, sim_object) - ... - status.graph_node_count += 1 - - # Create the new organ as a child of the phytomer: - st_repro_organ = add_organ!( - status.node[1], # The phytomer's internode is its first child - sim_object, # The simulation object, so we can add the new status - "+", status.sex, 4; - index=status.phytomer_count, - id=status.graph_node_count, - attributes=Dict{Symbol,Any}() - ) - - # Compute the initiation age of the organ: - PlantSimEngine.run!(sim_object.models[status.sex].initiation_age, sim_object.models[status.sex], st_repro_organ, meteo, constants, sim_object) - PlantSimEngine.run!(sim_object.models[status.sex].final_potential_biomass, sim_object.models[status.sex], st_repro_organ, meteo, constants, sim_object) -end -``` - -In the above example the organ and its status template are created on the fly. -When that isn't the case, the status template can be accessed through the simulation graph : - -```julia -function PlantSimEngine.run!(m::ReproductiveOrganEmission, models, status, meteo, constants, sim_object) - - ... - - if status.sex == :Male - - status_male = sim_object.statuses[:Male][1] - run!(sim_object.models[:Male].initiation_age, models, status_male, meteo, constants, sim_object) - run!(sim_object.models[:Male].final_potential_biomass, models, status_male, meteo, constants, sim_object) - else - # Female - ... - end -end -``` diff --git a/docs/src/multiscale/multiscale_cyclic.md b/docs/src/multiscale/multiscale_cyclic.md deleted file mode 100644 index f0c62bb72..000000000 --- a/docs/src/multiscale/multiscale_cyclic.md +++ /dev/null @@ -1,115 +0,0 @@ -# Avoiding cyclic dependencies - -When defining a mapping between models and scales, it is important to avoid cyclic dependencies. A cyclic dependency occurs when a model at a given scale depends on a model at another scale that depends on the first model. Cyclic dependencies are bad because they lead to an infinite loop in the simulation (the dependency graph keeps cycling indefinitely). - -PlantSimEngine will detect cyclic dependencies and raise an error if one is found. The error message indicates the models involved in the cycle, and the model that is causing the cycle will be highlighted in red. - -For example the following mapping will raise an error: - -!!! details - Example mapping - - ```julia - mapping_cyclic = ModelMapping( - :Plant => ( - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - Status(total_surface=0.001, aPPFD=1300.0, soil_water_content=0.6), - ), - :Internode => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(TT=10.0, carbon_biomass=1.0), - ), - :Leaf => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - ToyCBiomassModel(1.2), - Status(TT=10.0), - ) - ) - ``` - -Let's see what happens when we try to build the dependency graph for this mapping: - -```julia -julia> dep(mapping_cyclic) -ERROR: Cyclic dependency detected in the graph. Cycle: - Plant: ToyPlantRmModel - └ Leaf: ToyMaintenanceRespirationModel - └ Leaf: ToyCBiomassModel - └ Plant: ToyCAllocationModel - └ Plant: ToyPlantRmModel - - You can break the cycle using the `PreviousTimeStep` variable in the mapping. -``` - -How can we interpret the message? We have a list of five models involved in the cycle. The first model is the one causing the cycle, and the others are the ones that depend on it. In this case, the `ToyPlantRmModel` is the one causing the cycle, and the others are inter-dependent. We can read this as follows: - -1. `ToyPlantRmModel` depends on `ToyMaintenanceRespirationModel`, the plant-scale respiration sums up all organs respiration; -2. `ToyMaintenanceRespirationModel` depends on `ToyCBiomassModel`, the organs respiration depends on the organs biomass; -3. `ToyCBiomassModel` depends on `ToyCAllocationModel`, the organs biomass depends on the organs carbon allocation; -4. And finally `ToyCAllocationModel` depends on `ToyPlantRmModel` again, hence the cycle because the carbon allocation depends on the plant scale respiration. - -The models can not be ordered in a way that satisfies all dependencies, so the cycle can not be broken. To solve this issue, we need to re-think how models are mapped together, and break the cycle. - -There are several ways to break a cyclic dependency: - -- **Merge models**: If two models depend on each other because they need *e.g.* recursive computations, they can be merged into a third model that handles the computation and takes the two models as hard dependencies. Hard dependencies are models that are explicitly called by another model and do not participate on the building of the dependency graph. -- **Change models**: Of course models can be interchanged to avoid cyclic dependencies, but this is not really a solution, it is more a workaround. -- **PreviousTimeStep**: We can break the dependency graph by defining some variables as taken from the previous time step. A very well known example is the computation of the light interception by a plant that depends on the leaf area, which is usually the result of a model that also depends on the light interception. The cyclic dependency is usually broken by using the leaf area from the previous time step in the interception model, which is a good approximation for most cases. - -We can fix our previous mapping by computing the organs respiration using the carbon biomass from the previous time step instead. Let's see how to fix the cyclic dependency in our mapping (look at the leaf and internode scales): - -!!! details - ```@julia - mapping_nocyclic = ModelMapping( - :Plant => ( - MultiScaleModel( - model=ToyCAllocationModel(), - mapping=[ - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - Status(total_surface=0.001, aPPFD=1300.0, soil_water_content=0.6, carbon_assimilation=5.0), - ), - :Internode => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - MultiScaleModel( - model=ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - mapped_variables=[PreviousTimeStep(:carbon_biomass),], #! this is where we break the cyclic dependency (first break) - ), - Status(TT=10.0, carbon_biomass=1.0), - ), - :Leaf => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - MultiScaleModel( - model=ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - mapped_variables=[PreviousTimeStep(:carbon_biomass),], #! this is where we break the cyclic dependency (second break) - ), - ToyCBiomassModel(1.2), - Status(TT=10.0), - ) - ); - nothing # hide - ``` - -The `ToyMaintenanceRespirationModel` models are now defined as [`MultiScaleModel`](@ref), and the `carbon_biomass` variable is wrapped in a `PreviousTimeStep` structure. This structure tells PlantSimEngine to take the value of the variable from the previous time step, breaking the cyclic dependency. - -!!! note - [`PreviousTimeStep`](@ref) tells PlantSimEngine to take the value of the previous time step for the variable it wraps, or the value at initialization for the first time step. The value at initialization is the one provided by default in the models inputs, but is usually provided in the [`Status`](@ref) structure to override this default. - A [`PreviousTimeStep`](@ref) is used to wrap the **input** variable of a model, with or without a mapping to another scale *e.g.* `PreviousTimeStep(:carbon_biomass) => :Leaf`. \ No newline at end of file diff --git a/docs/src/multiscale/multiscale_example_1.md b/docs/src/multiscale/multiscale_example_1.md deleted file mode 100644 index 19dd671e0..000000000 --- a/docs/src/multiscale/multiscale_example_1.md +++ /dev/null @@ -1,289 +0,0 @@ -# Writing a multiscale simulation - -This three-part subsection walks you through building a multi-scale simulation from scratch. It is meant as an illustration of the iterative process you might go through when building and slowly tuning a Functional-Structural Plant Model, where previous multi-scale examples focused more on the API syntax. - -You can find the full script for the first part's toy simulation in the [ToyMultiScalePlantModel](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples/ToyMultiScalePlantModel/ToyPlantSimulation1.jl) subfolder of the examples folder. - -```@contents -Pages = ["multiscale_example_1.md"] -Depth = 3 -``` - -## Disclaimer - -The actual plant being created, as well as some of the custom models, have no real physical meaning and are very much ad hoc (which is why most of them aren't standalone in the examples folder). Similarly, some of the parameter values are pulled out of thin air, and have no ties to research papers or data. - -The main purpose here is to showcase PlantSimEngine's multi-scale features and how to structure your models, not accuracy, realism or performance. - -## Initial setup - -We'll need to make use of a few packages, as usual, after adding them to our Julia environment: - -```@example usepkg -using PlantSimEngine -using PlantSimEngine.Examples # to import the ToyDegreeDaysCumulModel model -using PlantMeteo, Dates -using MultiScaleTreeGraph # multi-scale -``` - -## A basic growing plant - -At minimum, to simulate some kind of fake growth, we need : - -- A Multi-scale Tree Graph representing the plant -- Some way of adding organs to the plant -- Some kind of temporality to spread this growth over multiple timesteps - -Let's have some concept of 'leaves' that capture the (carbon) resource necessary for organ growth, and let's have the organ emergence happen at the 'internode' level, to illustrate multiple organs with different behavior. - -We'll make the assumption that the internodes make use of carbon from a common pool. We'll also make use of thermal time as a growth delay factor. - -To sum up, we have: -- a MTG with growing internodes and leaves -- Individual leaves that capture carbon fed into a common pool -- Internodes which take from that pool to create new organs, with a thermal time constraint. - -One way of modeling this approach translates into several scales and models: - -- a Scene scale, for thermal time. The [`ToyDegreeDaysCumulModel`](@ref) from the [examples folder](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples/ToyDegreeDays.jl) provides thermal time from temperature data -- a Plant scale, where we'll define the carbon pool -- an Internode scale, which draws from the pool to create new organs -- a Leaf scale, which captures carbon - -Let's also add a very artificial limiting factor: if the total leaf surface area is above a threshold no new organs are created. - -We can expect the simulation mapping to look like a more complex version of the following: - -```julia -mapping = ModelMapping( -:Scene => ToyDegreeDaysCumulModel(), -:Plant => ToyStockComputationModel(), -:Internode => ToyCustomInternodeEmergence(), -:Leaf => ToyLeafCarbonCaptureModel(), -) -``` - -Some of the models will need to gather variables from scales other than their own, meaning they will need to be converted into MultiScaleModels. - -## Implementation - -### Carbon Capture - -Let's start with the simplest model. Our fake leaves will continuously capture some constant amount of carbon every timestep. No inputs or parameters are required. - -```@example usepkg -PlantSimEngine.@process "leaf_carbon_capture" verbose = false - -struct ToyLeafCarbonCaptureModel<: AbstractLeaf_Carbon_CaptureModel end - -function PlantSimEngine.inputs_(::ToyLeafCarbonCaptureModel) - NamedTuple() # No inputs -end - -function PlantSimEngine.outputs_(::ToyLeafCarbonCaptureModel) - (carbon_captured=0.0,) -end - -function PlantSimEngine.run!(::ToyLeafCarbonCaptureModel, models, status, meteo, constants, extra) - status.carbon_captured = 40 -end -``` - -### Resource storage - -The model storing resources for the whole plant needs a couple of inputs: the amount of carbon captured by the leaves, as well as the amount consumed by the creation of new organs. It outputs the current stock. - -```@example usepkg -PlantSimEngine.@process "resource_stock_computation" verbose = false - -struct ToyStockComputationModel <: AbstractResource_Stock_ComputationModel -end - -PlantSimEngine.inputs_(::ToyStockComputationModel) = -(carbon_captured=0.0,carbon_organ_creation_consumed=0.0) - -PlantSimEngine.outputs_(::ToyStockComputationModel) = (carbon_stock=-Inf,) - -function PlantSimEngine.run!(m::ToyStockComputationModel, models, status, meteo, constants=nothing, extra=nothing) - status.carbon_stock += sum(status.carbon_captured) - sum(status.carbon_organ_creation_consumed) -end -``` - -### Organ creation - -This model is a modified version of the ToyInternodeEmergence model found [in the examples folder](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples/ToyInternodeEmergence.jl). An internode produces two leaves and a new internode. - -Let's first define a helper function that iterates across a Multiscale Tree Graph and returns the number of leaves : - -```@example usepkg -function get_n_leaves(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - nleaves = length(MultiScaleTreeGraph.traverse(root, x->1, symbol=:Leaf)) - return nleaves -end -``` - -Now that we have that, let's define a few parameters to the model. It requires : -- a thermal time emergence threshold -- a carbon cost for organ creation - -We'll also add a couple of other parameters, which could go elsewhere : -- the surface area of a leaf (no variation, no growth stages) -- the max leaf surface area beyond which organ creation stops - -```@example usepkg -PlantSimEngine.@process "organ_emergence" verbose = false - -struct ToyCustomInternodeEmergence{T} <: AbstractOrgan_EmergenceModel - TT_emergence::T - carbon_internode_creation_cost::T - leaf_surface_area::T - leaves_max_surface_area::T -end -``` - -!!! note - We make use of parametric types instead of the intuitive Float64 for flexibility. See [Parametric types](@ref) for a more in-depth explanation - -And give them some default values : - -```@example usepkg -ToyCustomInternodeEmergence(;TT_emergence=300.0, carbon_internode_creation_cost=200.0, leaf_surface_area=3.0, leaves_max_surface_area=100.0) = ToyCustomInternodeEmergence(TT_emergence, carbon_internode_creation_cost, leaf_surface_area, leaves_max_surface_area) -``` - -Our internode model requires thermal time, and the amount of available carbon, and outputs the amount of carbon consumed, as well as the last thermal time where emergence happened (this is useful when new organs can be produced multiple times, which won't be the case here). - -```@example usepkg -PlantSimEngine.inputs_(m::ToyCustomInternodeEmergence) = (TT_cu=0.0, carbon_stock=0.0) -PlantSimEngine.outputs_(m::ToyCustomInternodeEmergence) = (TT_cu_emergence=0.0, carbon_organ_creation_consumed=0.0) -``` -Finally, the [`run!`](@ref) function checks that conditions are met for new organ creation : -- thermal time threshold exceeded -- total leaf surface area not above limit -- carbon available -- no organs already created by that internode - -and then updates the MTG. - -```@example usepkg -function PlantSimEngine.run!(m::ToyCustomInternodeEmergence, models, status, meteo, constants=nothing, sim_object=nothing) - - leaves_surface_area = m.leaf_surface_area * get_n_leaves(status.node) - status.carbon_organ_creation_consumed = 0.0 - - if leaves_surface_area > m.leaves_max_surface_area - return nothing - end - - # if not enough carbon, no organ creation - if status.carbon_stock < m.carbon_internode_creation_cost - return nothing - end - - if length(MultiScaleTreeGraph.children(status.node)) == 2 && - status.TT_cu - status.TT_cu_emergence >= m.TT_emergence - status_new_internode = add_organ!(status.node, sim_object, "<", :Internode, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - - status_new_internode.TT_cu_emergence = m.TT_emergence - status.TT_cu - status.carbon_organ_creation_consumed = m.carbon_internode_creation_cost - end - - return nothing -end -``` - -### Updated mapping - -We can now define the final mapping for this simulation. - -The carbon capture and thermal time models don't need to be changed from the earlier version. -The organ creation model at the :Internode scale needs the carbon stock from the :Plant scale, as well as thermal time from the :Scene scale. -The resource storing model at the :Plant scale needs the carbon captured by **every** leaf, and the carbon consumed by **every** internode that created new organs this timestep. This requires mapping vector variables : - -```julia - mapped_variables=[ - :carbon_captured=>[:Leaf], - :carbon_organ_creation_consumed=>[:Internode] - ], -``` -as opposed to the single-valued carbon stock mapped variable : - -```julia - mapped_variables=[:TT_cu => :Scene, - PreviousTimeStep(:carbon_stock)=>:Plant], -``` - -And of course, some variables need to be initialized in the status: - -```@example usepkg -mapping = ModelMapping( -:Scene => ToyDegreeDaysCumulModel(), -:Plant => ( - MultiScaleModel( - model=ToyStockComputationModel(), - mapped_variables=[ - :carbon_captured=>[:Leaf], - :carbon_organ_creation_consumed=>[:Internode] - ], - ), - Status(carbon_stock = 0.0) - ), -:Internode => ( - MultiScaleModel( - model=ToyCustomInternodeEmergence(),#TT_emergence=20.0), - mapped_variables=[:TT_cu => :Scene, - PreviousTimeStep(:carbon_stock)=>:Plant], - ), - Status(carbon_organ_creation_consumed=0.0), - ), -:Leaf => ToyLeafCarbonCaptureModel(), -) -``` - -!!! note - This excerpt (and the complete script file) showcase the final properly initialized mapping, but when developing, you are encouraged to make liberal use of the helper function [`to_initialize`](@ref) and check the PlantSimEngine user errors. - -### Running a simulation - -We only need an MTG, and some weather data, and then we'll be set. Let's create a simple MTG : - -```@example usepkg -mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) -plant = MultiScaleTreeGraph.Node(mtg, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - -internode1 = MultiScaleTreeGraph.Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) -MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) -MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - -internode2 = MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("<", :Internode, 1, 2)) -MultiScaleTreeGraph.Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) -MultiScaleTreeGraph.Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) -``` - -Import some weather data: - -```@example usepkg -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) -nothing # hide -``` - -And we're good to go ! - -```@example usepkg -outs = run!(mtg, mapping, meteo_day) -``` - -If you query or display the MTG after simulation, you'll see it expanded and grew multiple internodes and leaves : - -```@example usepkg -mtg -#get_n_leaves(mtg) -``` - -And that's it ! Feel free to tinker with the parameters and see when things break down, to get a feel for the simulation. - -Of course, this is a very crude and unrealistic simulation, with many dubious assumptions and parameters. But significantly more complex modelling is possible using the same approach : XPalm runs using a few dozen models spread out over nine scales. - -This is a three-part tutorial and continues in the [Expanding on the multiscale simulation](@ref) page. \ No newline at end of file diff --git a/docs/src/multiscale/multiscale_example_2.md b/docs/src/multiscale/multiscale_example_2.md deleted file mode 100644 index 539399ddf..000000000 --- a/docs/src/multiscale/multiscale_example_2.md +++ /dev/null @@ -1,266 +0,0 @@ -# Expanding on the multiscale simulation - -Let's build on the previous example and add some other organ growth, as well as some very mild coupling between the two. - -You can find the full script for this simulation in the [ToyMultiScalePlantModel](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples/ToyMultiScalePlantModel/ToyPlantSimulation2.jl) subfolder of the examples folder. - -```@contents -Pages = ["multiscale_example_2.md"] -Depth = 3 -``` - -## Setup - -Once again, with a properly set-up Julia environment: - -```@example usepkg -using PlantSimEngine -using PlantSimEngine.Examples -using PlantMeteo, Dates, Dates -using MultiScaleTreeGraph - -PlantSimEngine.@process "leaf_carbon_capture" verbose = false - -struct ToyLeafCarbonCaptureModel<: AbstractLeaf_Carbon_CaptureModel end - -function PlantSimEngine.inputs_(::ToyLeafCarbonCaptureModel) - NamedTuple() # No inputs -end - -function PlantSimEngine.outputs_(::ToyLeafCarbonCaptureModel) - (carbon_captured=0.0,) -end - -function PlantSimEngine.run!(::ToyLeafCarbonCaptureModel, models, status, meteo, constants, extra) - status.carbon_captured = 40 -end - -function get_n_leaves(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - nleaves = length(MultiScaleTreeGraph.traverse(root, x->1, symbol=:Leaf)) - return nleaves -end -``` - -## Adding roots to our plant - -We'll add a root that extracts water and adds it to the stock. Initial water stocks are low, so root growth is prioritized, then the plant also grows leaves and a new internode like it did before. Roots only grow up to a certain point, and don't branch. - -This leads to adding a new scale, :Root to the mapping, as well as two more models, one for water absorption, the other for root growth. Other models are updated here and there to account for water. The carbon capture model remains unchanged, and so is the `get_n_leaves` helper function. - -## Root models - -### Water absorption - -Let's implement a very fake model of root water absorption. It'll capture the amount of precipitation in the weather data multiplied by some assimilation factor. - -```@example usepkg -PlantSimEngine.@process "water_absorption" verbose = false - -struct ToyWaterAbsorptionModel <: AbstractWater_AbsorptionModel -end - -PlantSimEngine.inputs_(::ToyWaterAbsorptionModel) = (root_water_assimilation=1.0,) -PlantSimEngine.outputs_(::ToyWaterAbsorptionModel) = (water_absorbed=0.0,) - -function PlantSimEngine.run!(m::ToyWaterAbsorptionModel, models, status, meteo, constants=nothing, extra=nothing) - status.water_absorbed = meteo.Precipitations * status.root_water_assimilation -end -``` - -### Root growth - -The root growth model is similar to the internode growth one : it checks for a water threshold and that there is enough carbon, and adds a new organ to the MTG if the maximum length hasn't been reached. - -It also makes use of a couple of helper functions to find the end root and compute root length : - -```@example usepkg -function get_root_end_node(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - return MultiScaleTreeGraph.traverse(root, x->x, symbol=:Root, filter_fun = MultiScaleTreeGraph.isleaf) -end - -function get_roots_count(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - return length(MultiScaleTreeGraph.traverse(root, x->x, symbol=:Root)) -end - -PlantSimEngine.@process "root_growth" verbose = false - -struct ToyRootGrowthModel{T} <: AbstractRoot_GrowthModel - water_threshold::T - carbon_root_creation_cost::T - root_max_len::Int -end - -PlantSimEngine.inputs_(::ToyRootGrowthModel) = (water_stock=0.0,carbon_stock=0.0,) -PlantSimEngine.outputs_(::ToyRootGrowthModel) = (carbon_root_creation_consumed=0.0,) - -function PlantSimEngine.run!(m::ToyRootGrowthModel, models, status, meteo, constants=nothing, extra=nothing) - if status.water_stock < m.water_threshold && status.carbon_stock > m.carbon_root_creation_cost - - root_end = get_root_end_node(status.node) - - if length(root_end) != 1 - throw(AssertionError("Couldn't find MTG leaf node with symbol \"Root\"")) - end - root_len = get_roots_count(root_end[1]) - if root_len < m.root_max_len - st = add_organ!(root_end[1], extra, "<", :Root, 2, index=1) - status.carbon_root_creation_consumed = m.carbon_root_creation_cost - end - else - status.carbon_root_creation_consumed = 0.0 - end -end -``` - -## Updating other models to account for water - -### Resource storage - -Water absorbed must now be accumulated, and root carbon creation costs taken into account. - -```@example usepkg -PlantSimEngine.@process "resource_stock_computation" verbose = false - -struct ToyStockComputationModel <: AbstractResource_Stock_ComputationModel -end - -PlantSimEngine.inputs_(::ToyStockComputationModel) = -(water_absorbed=0.0,carbon_captured=0.0,carbon_organ_creation_consumed=0.0,carbon_root_creation_consumed=0.0) - -PlantSimEngine.outputs_(::ToyStockComputationModel) = (water_stock=-Inf,carbon_stock=-Inf) - -function PlantSimEngine.run!(m::ToyStockComputationModel, models, status, meteo, constants=nothing, extra=nothing) - status.water_stock += sum(status.water_absorbed) - status.carbon_stock += sum(status.carbon_captured) - sum(status.carbon_organ_creation_consumed) - sum(status.carbon_root_creation_consumed) -end -``` - -### Internode creation - -The minor change is that new organs are now created only if the water stock is above a given threshold. - -```@example usepkg -struct ToyCustomInternodeEmergence{T} <: AbstractOrgan_EmergenceModel - TT_emergence::T - carbon_internode_creation_cost::T - leaf_surface_area::T - leaves_max_surface_area::T - water_leaf_threshold::T -end - -ToyCustomInternodeEmergence(;TT_emergence=300.0, carbon_internode_creation_cost=200.0, leaf_surface_area=3.0,leaves_max_surface_area=100.0, -water_leaf_threshold=30.0) = ToyCustomInternodeEmergence(TT_emergence, carbon_internode_creation_cost, leaf_surface_area, leaves_max_surface_area, water_leaf_threshold) - -PlantSimEngine.inputs_(m::ToyCustomInternodeEmergence) = (TT_cu=0.0,water_stock=0.0, carbon_stock=0.0) -PlantSimEngine.outputs_(m::ToyCustomInternodeEmergence) = (TT_cu_emergence=0.0, carbon_organ_creation_consumed=0.0) - -function PlantSimEngine.run!(m::ToyCustomInternodeEmergence, models, status, meteo, constants=nothing, sim_object=nothing) - - leaves_surface_area = m.leaf_surface_area * get_n_leaves(status.node) - status.carbon_organ_creation_consumed = 0.0 - - if leaves_surface_area > m.leaves_max_surface_area - return nothing - end - - # if water levels are low, prioritise roots - if status.water_stock < m.water_leaf_threshold - return nothing - end - - # if not enough carbon, no organ creation - if status.carbon_stock < m.carbon_internode_creation_cost - return nothing - end - - if length(MultiScaleTreeGraph.children(status.node)) == 2 && - status.TT_cu - status.TT_cu_emergence >= m.TT_emergence - status_new_internode = add_organ!(status.node, sim_object, "<", :Internode, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - - status_new_internode.TT_cu_emergence = m.TT_emergence - status.TT_cu - status.carbon_organ_creation_consumed = m.carbon_internode_creation_cost - end - - return nothing -end -``` - -## Updating the mapping - -The resource storage and internode emergence models now need a couple of extra water-related mapped variables. -The :Root organ is added to the mapping with its own models. New parameters need to be initialized. - -```@example usepkg -mapping = ModelMapping( -:Scene => ToyDegreeDaysCumulModel(), -:Plant => ( - MultiScaleModel( - model=ToyStockComputationModel(), - mapped_variables=[ - :carbon_captured=>[:Leaf], - :water_absorbed=>[:Root], - :carbon_root_creation_consumed=>[:Root], - :carbon_organ_creation_consumed=>[:Internode] - - ], - ), - Status(water_stock = 0.0, carbon_stock = 0.0) - ), -:Internode => ( - MultiScaleModel( - model=ToyCustomInternodeEmergence(),#TT_emergence=20.0), - mapped_variables=[:TT_cu => :Scene, - PreviousTimeStep(:water_stock)=>:Plant, - PreviousTimeStep(:carbon_stock)=>:Plant], - ), - Status(carbon_organ_creation_consumed=0.0), - ), -:Root => ( MultiScaleModel( - model=ToyRootGrowthModel(10.0, 50.0, 10), - mapped_variables=[PreviousTimeStep(:carbon_stock)=>:Plant, - PreviousTimeStep(:water_stock)=>:Plant], - ), - ToyWaterAbsorptionModel(), - Status(carbon_root_creation_consumed=0.0, root_water_assimilation=1.0), - ), -:Leaf => ( ToyLeafCarbonCaptureModel(),), -) -``` - -## Running the simulation - -Running this new simulation is almost the same as before. The weather data is unchanged, but a new :Root node was added to the MTG. - -```@example usepkg -mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) - plant = MultiScaleTreeGraph.Node(mtg, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - - internode1 = MultiScaleTreeGraph.Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) - MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - - internode2 = MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("<", :Internode, 1, 2)) - MultiScaleTreeGraph.Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - MultiScaleTreeGraph.Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - - plant_root_start = MultiScaleTreeGraph.Node( - plant, - MultiScaleTreeGraph.NodeMTG("+", :Root, 1, 3), - ) - -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -outs = run!(mtg, mapping, meteo_day) -mtg -``` - -And that's it ! - -...Or is it ? - -If you inspect the code and output data closely, you may notice some distinctive problems with the way the simulation runs... Some things aren't quite right. If you wish to know more, onwards to the next chapter: [Fixing bugs in the plant simulation](@ref) \ No newline at end of file diff --git a/docs/src/multiscale/multiscale_example_3.md b/docs/src/multiscale/multiscale_example_3.md deleted file mode 100644 index 582ec984c..000000000 --- a/docs/src/multiscale/multiscale_example_3.md +++ /dev/null @@ -1,503 +0,0 @@ -# Fixing bugs in the plant simulation - -```@setup usepkg -using PlantSimEngine -using PlantSimEngine.Examples -using PlantMeteo, Dates -using MultiScaleTreeGraph -function get_root_end_node(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - return MultiScaleTreeGraph.traverse(root, x->x, symbol=:Root, filter_fun = MultiScaleTreeGraph.isleaf) -end - -function get_roots_count(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - return length(MultiScaleTreeGraph.traverse(root, x->x, symbol=:Root)) -end - -function get_n_leaves(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - nleaves = length(MultiScaleTreeGraph.traverse(root, x->1, symbol=:Leaf)) - return nleaves -end - -PlantSimEngine.@process "organ_emergence" verbose = false - -struct ToyCustomInternodeEmergence{T} <: AbstractOrgan_EmergenceModel - TT_emergence::T - carbon_internode_creation_cost::T - leaf_surface_area::T - leaves_max_surface_area::T - water_leaf_threshold::T -end - -ToyCustomInternodeEmergence(;TT_emergence=300.0, carbon_internode_creation_cost=200.0, leaf_surface_area=3.0,leaves_max_surface_area=100.0, -water_leaf_threshold=30.0) = ToyCustomInternodeEmergence(TT_emergence, carbon_internode_creation_cost, leaf_surface_area, leaves_max_surface_area, water_leaf_threshold) - -PlantSimEngine.inputs_(m::ToyCustomInternodeEmergence) = (TT_cu=0.0,water_stock=0.0, carbon_stock=0.0) -PlantSimEngine.outputs_(m::ToyCustomInternodeEmergence) = (TT_cu_emergence=0.0, carbon_organ_creation_consumed=0.0) - -function PlantSimEngine.run!(m::ToyCustomInternodeEmergence, models, status, meteo, constants=nothing, sim_object=nothing) - - leaves_surface_area = m.leaf_surface_area * get_n_leaves(status.node) - status.carbon_organ_creation_consumed = 0.0 - - if leaves_surface_area > m.leaves_max_surface_area - return nothing - end - - # if water levels are low, prioritise roots - if status.water_stock < m.water_leaf_threshold - return nothing - end - - # if not enough carbon, no organ creation - if status.carbon_stock < m.carbon_internode_creation_cost - return nothing - end - - if length(MultiScaleTreeGraph.children(status.node)) == 2 && - status.TT_cu - status.TT_cu_emergence >= m.TT_emergence - status_new_internode = add_organ!(status.node, sim_object, "<", :Internode, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - - status_new_internode.TT_cu_emergence = m.TT_emergence - status.TT_cu - status.carbon_organ_creation_consumed = m.carbon_internode_creation_cost - end - - return nothing -end - -############################ -# Naive water absorption model -# Absorbs precipitation water depending on quantity of roots -############################ -PlantSimEngine.@process "water_absorption" verbose = false - -struct ToyWaterAbsorptionModel <: AbstractWater_AbsorptionModel -end - -PlantSimEngine.inputs_(::ToyWaterAbsorptionModel) = (root_water_assimilation=1.0,) -PlantSimEngine.outputs_(::ToyWaterAbsorptionModel) = (water_absorbed=0.0,) - -function PlantSimEngine.run!(m::ToyWaterAbsorptionModel, models, status, meteo, constants=nothing, extra=nothing) - #root_end = get_root_end_node(status.node) - #root_len = root_end[:Root_len] - status.water_absorbed = meteo.Precipitations * status.root_water_assimilation #* root_len -end - -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyWaterAbsorptionModel}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyWaterAbsorptionModel}) = PlantSimEngine.IsObjectIndependent() - - -########################## -### Root growth : when water stocks are low, expand root -########################## - -PlantSimEngine.@process "root_growth" verbose = false - -struct ToyRootGrowthModel{T} <: AbstractRoot_GrowthModel - water_threshold::T - carbon_root_creation_cost::T - root_max_len::Int -end - -PlantSimEngine.inputs_(::ToyRootGrowthModel) = (water_stock=0.0,carbon_stock=0.0,) -PlantSimEngine.outputs_(::ToyRootGrowthModel) = (carbon_root_creation_consumed=0.0,) - -function PlantSimEngine.run!(m::ToyRootGrowthModel, models, status, meteo, constants=nothing, extra=nothing) - if status.water_stock < m.water_threshold && status.carbon_stock > m.carbon_root_creation_cost - - root_end = get_root_end_node(status.node) - - if length(root_end) != 1 - throw(AssertionError("Couldn't find MTG leaf node with symbol \"Root\"")) - end - root_len = get_roots_count(root_end[1]) - if root_len < m.root_max_len - st = add_organ!(root_end[1], extra, "<", :Root, 2, index=1) - status.carbon_root_creation_consumed = m.carbon_root_creation_cost - end - else - status.carbon_root_creation_consumed = 0.0 - end -end - -########################## -### Model accumulating carbon and water resources -########################## - -PlantSimEngine.@process "resource_stock_computation" verbose = false - -struct ToyStockComputationModel <: AbstractResource_Stock_ComputationModel -end -#status.water_stock += meteo.precipitations * root_water_assimilation_ratio - -PlantSimEngine.inputs_(::ToyStockComputationModel) = -(water_absorbed=0.0,carbon_captured=0.0,carbon_organ_creation_consumed=0.0,carbon_root_creation_consumed=0.0) - -PlantSimEngine.outputs_(::ToyStockComputationModel) = (water_stock=-Inf,carbon_stock=-Inf) - -function PlantSimEngine.run!(m::ToyStockComputationModel, models, status, meteo, constants=nothing, extra=nothing) - status.water_stock += sum(status.water_absorbed) #- status.water_transpiration - status.carbon_stock += sum(status.carbon_captured) - sum(status.carbon_organ_creation_consumed) - sum(status.carbon_root_creation_consumed) - - if status.water_stock < 0.0 - status.water_stock = 0.0 - end -end - -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyStockComputationModel}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyStockComputationModel}) = PlantSimEngine.IsObjectIndependent() - -######################## -## Leaf model capturing some arbitrary carbon quantity -######################## - -PlantSimEngine.@process "leaf_carbon_capture" verbose = false - -struct ToyLeafCarbonCaptureModel<: AbstractLeaf_Carbon_CaptureModel end - -function PlantSimEngine.inputs_(::ToyLeafCarbonCaptureModel) - NamedTuple()#(TT_cu=-Inf) -end - -function PlantSimEngine.outputs_(::ToyLeafCarbonCaptureModel) - (carbon_captured=0.0,) -end - -function PlantSimEngine.run!(::ToyLeafCarbonCaptureModel, models, status, meteo, constants, extra) - # very crude approximation with LAI of 1 and constant PPFD - status.carbon_captured = 200.0 *(1.0 - exp(-0.2)) -end - -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyLeafCarbonCaptureModel}) = PlantSimEngine.IsObjectIndependent() -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyLeafCarbonCaptureModel}) = PlantSimEngine.IsTimeStepIndependent() - -mapping = ModelMapping( -:Scene => ToyDegreeDaysCumulModel(), -:Plant => ( - MultiScaleModel( - model=ToyStockComputationModel(), - mapped_variables=[ - :carbon_captured=>[:Leaf], - :water_absorbed=>[:Root], - :carbon_root_creation_consumed=>[:Root], - :carbon_organ_creation_consumed=>[:Internode] - - ], - ), - Status(water_stock = 0.0, carbon_stock = 0.0) - ), -:Internode => ( - MultiScaleModel( - model=ToyCustomInternodeEmergence(),#TT_emergence=20.0), - mapped_variables=[:TT_cu => :Scene, - PreviousTimeStep(:water_stock)=>:Plant, - PreviousTimeStep(:carbon_stock)=>:Plant], - ), - Status(carbon_organ_creation_consumed=0.0), - ), -:Root => ( MultiScaleModel( - model=ToyRootGrowthModel(10.0, 50.0, 10), - mapped_variables=[PreviousTimeStep(:carbon_stock)=>:Plant, - PreviousTimeStep(:water_stock)=>:Plant], - ), - ToyWaterAbsorptionModel(), - Status(carbon_root_creation_consumed=0.0, root_water_assimilation=1.0), - ), -:Leaf => ( ToyLeafCarbonCaptureModel(),), -) - -mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) - -plant = MultiScaleTreeGraph.Node(mtg, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - -internode1 = MultiScaleTreeGraph.Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) -MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) -MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - -internode2 = MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("<", :Internode, 1, 2)) -MultiScaleTreeGraph.Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) -MultiScaleTreeGraph.Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - -plant_root_start = MultiScaleTreeGraph.Node( - plant, - MultiScaleTreeGraph.NodeMTG("+", :Root, 1, 3), -) - -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -``` - -There are two major issues hinted at in last chapter's implementation, which we'll discuss and resolve here. - -You can find the full script for this simulation in the [ToyMultiScalePlantModel](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples/ToyMultiScalePlantModel/ToyPlantSimulation3.jl) subfolder of the examples folder. - -```@contents -Pages = ["multiscale_example_3.md"] -Depth = 3 -``` - -## An organ creation problem - -There is one quirk you may have noticed when inspecting the data : when a root expands, the new root is immediately active, and some models may act on it immediately... including the root growth model. Meaning this new root may also sprout another root in the same timestep, and so on. - -You can notice this by looking at the simulation's state during the first two timesteps: - -```@example usepkg -outs = run!(mtg, mapping, first(meteo_day, 2)) - -root_nodes_per_timestep = [0, 0] -for i in 1:length(outs[:Root]) - if outs[:Root][i].timestep < 3 - root_nodes_per_timestep[outs[:Root][i].timestep] += 1 - end -end - -root_nodes_per_timestep -``` - -Our root grew to full length within one timestep. Oops. - -This is an implementation decision in PlantSimEngine. **By default, newly created organs are active**, and models can affect them **as soon as they are created**. - -In our case, internode growth depends on a threshold thermal time value, which accumulates over several timesteps, so even though new internodes are immediately active, they can't themselves grow new organs within the same timestep. But as we've just showcased, we have a root problem. - -This quirk is also handled in [XPalm.jl](https://github.com/PalmStudio/XPalm.jl), a package using PlantSimEngine: some organs make use of state machines, and are considered "immature" when they are created. Immature organs cannot grow new organs until some conditions are met for their state to change. There are also other conditions governing organ emergence, such as specific threshold values relating to Thermal Time (see [here](https://github.com/PalmStudio/XPalm.jl/blob/433e1c47c743e7a53e764672818a43ed8feb10c6/src/plant/phytomer/leaves/phyllochron.jl#L46) for an example). - -!!! note - This implementation decision for new organs to be immediately active may be subject to change in future versions of PlantSimEngine. Also note that the way the dependency graph is structured determines the order in which models run. Meaning that which models are run before or after organ creation might change with new additions and updates to your mapping. Some models might run "one timestep later", see [Simulation order instability when adding models](@ref) for more details. - -!!! note - MTG node output data has a couple of subtleties, see [Multi-scale output data structure](@ref) for more details - -### Delaying organ maturity - -How do we avoid this extreme instant growth ? We can, of course, add some thermal time constraint. We could arbitrarily tinker with water resources. - -We can otherwise add a simple state machine variable to our root and internodes in the MTG, indicating a newly added organ is immature and cannot grow on the same timestep. Since our root doesn't branch, we can simply keep track of a single state variable. See the [State machines](@ref) section for some examples. - -In fact, we could change the scale at which the check is made to extend the root, and have another model call this one directly. This enables running this model only for the end root when those occasional timesteps when root growth is possible, instead of at every timestep for every root node. - -## A resource distribution bug - -Another problem you may have noticed, is that the water and carbon stock are computed by aggregating photosynthesis over leaves and absorption over roots... But they aren't always properly decremented when consumed ! - -If the end root grows, it outputs a `carbon_root_creation_consumed` value, but under certain conditions, we might also create other roots and internodes even when there shouldn't be enough carbon left for them. - -Indeed, if both the root and leaf water thresholds are met, and there is enough carbon for a single root or internode but not for both, and the root model runs before the internode model, both will use the carbon_stock variable prior to organ emission. The internode emission model won't account for the root carbon consumption. - -This occurs because `carbon_stock` is only computed once, and won't update until the next timestep. - -### Fixing resource computation: a root growth decision model - -To avoid that problem in our specific case, we can couple the root growth model and the internode emission model, and pass the `carbon_root_creation_consumed` variable to the internode emission model so that it can use an updated carbon stock. Or we could have an intermediate model recompute the new stock to pass along to the internode emission model. - -There is a section in the [Tips and workarounds] page discussing this situation and other potential solutions: [Having a variable simultaneously as input and output of a model](@ref). - -We'll go for the first option and couple the root growth and internode emission model. - -### Internode emission adjustments - -The only change required for our internode emission model is to take into account `carbon_root_creation_consumed` as a new input, map that variable from the :Root scale in our mapping, and compute the adjusted carbon stock. Here's the relevant excerpt in the [`run!`](@ref) function. - -```julia - # take into account that the stock may already be depleted - carbon_stock_updated_after_roots = status.carbon_stock - status.carbon_root_creation_consumed - - # if not enough carbon, no organ creation - if carbon_stock_updated_after_roots < m.carbon_internode_creation_cost - return nothing - end -``` - -### A multi-scale hard dependency appears - -Our root growth decision model inherits some of the responsibility from last chapter's root growth model, so inputs, parameters and condition checks will be similar. We'll let the root growth model keep the length check and only focus on resources. - -Since the decision model is now directly responsible for calling the actual root growth model, we need to declare that it requires a root growth model as a hard dependency and cannot be run standalone. - -This hard dependency is in fact multiscale, since both models operate at different scales, :Plant and :Root. You can read more about multi-scale hard dependencies in the [Handling dependencies in a multiscale context](@ref) page. - -Compared to the single-scale equivalent, the multi-scale declaration additionally requires mapping the scale: - -```julia -PlantSimEngine.dep(::ToyRootGrowthDecisionModel) = (root_growth=AbstractRoot_GrowthModel=>[:Root],) -``` - -The `status` argument [`run!`](@ref) function of the root growth decision model only contains variables from the :Plant scale, or explicitely mapped to this scale, which isn't the case for the root growth's variables. To make use of the root growth model's variables, we need to recover the [`status`](@ref) at the :Root scale. It is accessible from the `extra` argument in [`run!`](@ref)'s signature. - -In multi-scale simulations, this `extra` argument implicitely contains an object storing the simulation state. It contains the statuses at various scales, and all the models indexed per scale and process name. - -Access to the :Root status within the root growth decision model [`run!`](@ref) function is done like so: - -```julia -status_Root= extra_args.statuses[:Root][1] -``` - -It is then possible to call the root growth model from the parent's [`run!`](@ref) function: - -```julia -PlantSimEngine.run!(extra.models[:Root].root_growth, models, status_Root, meteo, constants, extra) -``` - -Which will enable writing the rest of the [`run!`](@ref) function. - -### Root growth decision model implementation - -With that new coupling consideration properly handled, we can complete the full model implementation: - -```julia -PlantSimEngine.@process "root_growth_decision" verbose = false - -struct ToyRootGrowthDecisionModel{T} <: AbstractRoot_Growth_DecisionModel - water_threshold::T - carbon_root_creation_cost::T -end - -PlantSimEngine.inputs_(::ToyRootGrowthDecisionModel) = -(water_stock=0.0,carbon_stock=0.0) - -PlantSimEngine.outputs_(::ToyRootGrowthDecisionModel) = NamedTuple() - -PlantSimEngine.dep(::ToyRootGrowthDecisionModel) = (root_growth=AbstractRoot_GrowthModel=>[:Root],) - -# "status" is at the :Plant scale -function PlantSimEngine.run!(m::ToyRootGrowthDecisionModel, models, status, meteo, constants=nothing, extra=nothing) - - if status.water_stock < m.water_threshold && status.carbon_stock > m.carbon_root_creation_cost - # Obtain "status" at :Root scale - status_Root= extra_args.statuses[:Root][1] - # Call the hard dependency model directly with its status - PlantSimEngine.run!(extra.models[:Root].root_growth, models, status_Root, meteo, constants, extra) - end -end -``` - -The root growth model will output the `carbon_root_creation_consumed` computation, but it'll still be exposed to downstream models despite the root growth model being a 'hidden' model in the dependency graph due to its hard dependency nature. - -With this new coupling, we will only be creating at most a single new root per timestep, as the root growth decision will only be called once per timestep. - -### Root growth - -This iteration turns into a simplifed version of last chapter's. - -```julia -PlantSimEngine.@process "root_growth" verbose = false - -struct ToyRootGrowthModel <: AbstractRoot_GrowthModel - root_max_len::Int -end - -PlantSimEngine.inputs_(::ToyRootGrowthModel) = NamedTuple() -PlantSimEngine.outputs_(::ToyRootGrowthModel) = (carbon_root_creation_consumed=0.0,) - -function PlantSimEngine.run!(m::ToyRootGrowthModel, models, status, meteo, constants=nothing, extra=nothing) - status.carbon_root_creation_consumed = 0.0 - - root_end = get_root_end_node(status.node) - - if length(root_end) != 1 - throw(AssertionError("Couldn't find MTG leaf node with symbol \"Root\"")) - end - - root_len = get_roots_count(root_end[1]) - if root_len < m.root_max_len - st = add_organ!(root_end[1], extra, "<", :Root, 2, index=1) - status.carbon_root_creation_consumed = m.carbon_root_creation_cost - end -end -``` - -### Mapping adjustments - -The new mapping only has straightforward changes. Some models cease to be multi-scale, others require new variables to be mapped for them. `carbon_root_creation_consumed` ceases to be a vector mapping and is a scalar variable. - -```julia -mapping = ModelMapping( -:Scene => ToyDegreeDaysCumulModel(), -:Plant => ( - MultiScaleModel( - model=ToyStockComputationModel(), - mapped_variables=[ - :carbon_captured=>[:Leaf], - :water_absorbed=>[:Root], - :carbon_root_creation_consumed=>:Root, - :carbon_organ_creation_consumed=>[:Internode] - - ], - ), - MultiScaleModel( - model=ToyRootGrowthDecisionModel(10.0, 50.0), - ), - Status(water_stock = 0.0, carbon_stock = 0.0) - ), -:Internode => ( - MultiScaleModel( - model=ToyCustomInternodeEmergence(),#TT_emergence=20.0), - mapped_variables=[:TT_cu => :Scene, - :water_stock=>:Plant, - :carbon_stock=>:Plant, - :carbon_root_creation_consumed=>:Root], - ), - Status(carbon_organ_creation_consumed=0.0), - ), -:Root => (ToyRootGrowthModel(10), - ToyWaterAbsorptionModel(), - Status(carbon_root_creation_consumed=0.0, root_water_assimilation=1.0), - ), -:Leaf => ( ToyLeafCarbonCaptureModel(),), -) -``` - -We can now run our simulation as we did previously... or can we ? - -```julia -ERROR: Cyclic dependency detected for process resource_stock_computation: resource_stock_computation for organ Plant depends on root_growth from organ Root, which depends on the first one. This is not allowed, you may need to develop a new process that does the whole computation by itself. -``` - -Ah, it looks like our additional usage of the root carbon cost creates a cyclic dependency. - -### Breaking the dependency cycle - -Fortunately, the logic here is quite straightforward. We can't be computing our current timestep's resource stock with `carbon_root_creation_consumed`, and then updating it right after root creation again using a new value of `carbon_root_creation_consumed`. - -The solution is hopefully quite intuitive : when we compute resource stocks, we should be computing it using the previous timestep's values. Then root creation happens (or doesn't), and the computed `carbon_root_creation_consumed` corresponds to the current timestep value. We could also do the same for water to be consistent. - -### Updated mapping - -The relevant part of the mapping that needs to be updated is the following: - -```julia -mapping = ModelMapping( -... -:Plant => ( - MultiScaleModel( - model=ToyStockComputationModel(), - mapped_variables=[ - :carbon_captured=>[:Leaf], - :water_absorbed=>[:Root], - PreviousTimeStep(:carbon_root_creation_consumed)=>:Root, - PreviousTimeStep(:carbon_organ_creation_consumed)=>[:Internode], - ], - ), - ToyRootGrowthDecisionModel(10.0, 50.0), - Status(water_stock = 0.0, carbon_stock = 0.0) - ), -... -) -``` - -## Final words - -And you're now ready to run the simulation. - -The full script can be found [here](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples/ToyMultiScalePlantModel/ToyPlantSimulation3.jl), in the ToyMultiScalePlantModel subfolder of the examples folder. - -We now have a plant with two different growth directions. Roots are added at the beginning, until water is considered abundant enough. - -Of course, there are still several design issues with this implementation. It is as utterly unrealistic as the previous one, and doesn't even consume water. Some condition checking is a little ad hoc and could be made more robust. More sanity checks could be added, and the model and variable names could definitely be made more clear. - -But once again, this example is only made to illustrate what is possible with this framework, and doesn't strive for ecophysiological consistency. And the approach can be made increasingly more complex by refining models and simulation parameters, and feeding in new information about your plant, and ramp up to realistic, production-ready and predictive simulations. diff --git a/docs/src/multiscale/multiscale_example_4.md b/docs/src/multiscale/multiscale_example_4.md deleted file mode 100644 index f5105e84b..000000000 --- a/docs/src/multiscale/multiscale_example_4.md +++ /dev/null @@ -1,226 +0,0 @@ -# Visualizing a plant using PlantGeom - -We've created our toy plant, part of the fun is to actually visualize it ! - -Let's see how to do so with the [PlantGeom](https://github.com/VEZY/PlantGeom.jl) companion package. - -We'll be reusing the mtg from part 3 of the plant tutorial: [Fixing bugs in the plant simulation](@ref), so you need to run that simulation first, or to include the script file into your current code (which is what we'll do here): - -```julia -using PlantSimEngine -using MultiScaleTreeGraph -using PlantSimEngine.Examples -using Pkg -Pkg.add("CSV") -using CSV -include("ToyPlantSimulation3.jl") -``` - -You'll need to add PlantGeom and a compatible visualization package to your environment. We'll use Plots: - -```julia -using Plots -using PlantGeom -``` - -That's enough to get a nicer display of the MTG than the console-based printing. You'll only need to type the following line: - -```julia -RecipesBase.plot(mtg) -``` - -This provides the following visualization: -![MTG Plots visualization](../www/mtg_plot_1.svg) - -And that's it ! - -We can see the root expansion in one direction, and the internodes with their leaves in the other. - -Of course, that's good and all, but going beyond that would be nice. - -PlantGeom is able to render geometry from what it finds in the MTG. If a node in the tree graph has a `:geometry` attribute with a mesh and a transformation, it can make use of that to build a plant. That mesh can be unique per node, or based on a reference mesh that is copied and transformed for every node. - -!!! note - This page simply aims to illustrate PlantGeom's features and doesn't aim for a particularly realistic or aesthetic look. A little randomness could go a long way to make the plant look a little more life-like, but would also be very ad-hoc and make the code less clear. - -Our MTG doesn't have any such attribute, so we'll need to iterate on our nodes, provide them with a mesh and calculate appropriate transformations. We'll use one reference mesh for each plant-related scale, Internode, Root and Leaf. - -We'll make use of some of the Meshes primitives and transformations, as well as some helper functions from the packages TransformsBase and Rotations. For leaves, we'll read a .ply file using the PlyIO package, which contains a very unrealistic leaf + petiole mesh. - -We'll also make our plant opposite decussate: leaves come in pairs, and pairs are rotated by 90 degrees along the stem. - -The function that'll provide the geometry to the node is: -```julia -PlantGeom.Geometry(; ref_mesh<:RefMesh, transformation=Identity(), dUp=1.0, dDwn=1.0, mesh::Union{SimpleMesh,Nothing}=nothing) -``` - -We only care about the first two parameters in our case, and we can use a simple cylinder for each node of our single Internode stem and single Root. - -```julia -using PlantGeom.Meshes - -# Internodes and roots will use a cylinder as a mesh - -cylinder() = Meshes.CylinderSurface(1.0) |> Meshes.discretize |> Meshes.simplexify - -refmesh_internode = PlantGeom.RefMesh(:Internode, cylinder()) -refmesh_root = PlantGeom.RefMesh(:Root, cylinder()) -``` - -A simple function to read the vertices and faces from the .ply file for our leaves: - -```julia -Pkg.add("PlyIO") -using PlyIO -function read_ply(fname) - ply = PlyIO.load_ply(fname) - x = ply["vertex"]["x"] - y = ply["vertex"]["y"] - z = ply["vertex"]["z"] - points = Meshes.Point.(x, y, z) - connec = [Meshes.connect(Tuple(c .+ 1)) for c in ply["face"]["vertex_indices"]] - Meshes.SimpleMesh(points, connec) -end - -leaf_ply = read_ply("examples/leaf_with_petiole.ply") -refmesh_leaf = PlantGeom.RefMesh(:Leaf, leaf_ply) -``` - -```julia -Pkg.add("TransformsBase") -Pkg.add("Rotations") -import TransformsBase: → -import Rotations: RotY, RotZ, RotX -``` - -!!! note - We'll use X, Y, Z as standard cartersian coordinate axes, with Z pointing upwards. - -We can then write the function that adds the geometry to our MTG. - -It traverses the MTG, starting from the base, and adds a transformation for each encountered node. - -The following just operates on internodes, for clarity: - -```julia -# Add the geometry to the MTG, with transformations -function add_geometry!(mtg, refmesh_internode) - - # incremental offset - internode_height = 0.0 - - # relative scale of the base mesh - internode_width = 0.5 - - # length of the base mesh - internode_length = 1.0 - - traverse!(mtg) do node - if symbol(node) == :Internode - # Set to scale, then translate by the total height - mesh_transformation = Meshes.Scale(internode_width, internode_width, internode_length) → Meshes.Translate(0.0, 0.0, internode_height) - node.geometry = PlantGeom.Geometry(ref_mesh=refmesh_internode, transformation=mesh_transformation) - - internode_height += node_length - end - end -end -``` - -We simply need to choose a given width for our stem, and increment the height to place our next internode at as we traverse it. - -Note that the default cylinder provided by Meshes.jl points upwards, which is why there is no need for rotation. Roots function likewise, but are simply translated down, and need to start below the origin. - -We can visualize this simple stem, using GLMakie as a rendering backend: - -```julia -add_geometry!(mtg, refmesh_internode) - -# Visualize the mesh -using GLMakie -viz(mtg) -``` - -![Toy Plant - stem only](../www/toy_plant_stem_only.png) - -On the other hand, the leaf mesh will need to be rotated, but it is aligned along the X axis, so there is no need for an initial reorientation (which would have been required if it was pointing upwards like the cylinders). The petiole starts at the origin, so on top of translating them to leaf height we also need to translate them away from the Z axis by the internode radius. The mesh also needs to be scaled, as it is only 0.1 unit lengths long compared to our 0.5-width internode. - -Let's also rotate our leaves so that they point upwards slightly. - -If you make use of other meshes, bear in mind the initial starting translation, orientation and scale. You may need to test and calibrate scales and transformations before you get it right. - -The full code that generates geometry for all the organs of our toy plant is the following: -```julia -# Add the geometry to the MTG, with transformations -function add_geometry!(mtg, refmesh_internode, refmesh_root, refmesh_leaf) - - # incremental offset - internode_height = 0.0 - root_depth = 0.0 - - # relative scale of the base mesh (base cylinder is of height 1 and radius 1) - internode_width = 0.5 - root_width = 0.2 - - # length of the base mesh - internode_length = 1.0 - root_length = 1.0 - - # ad hoc value to adjust the leaf mesh to the scene scale - leaf_mesh_scale = 25 - - leaf_scale_width = 0.4*leaf_mesh_scale - leaf_scale_height = 0.4*leaf_mesh_scale - - # Helpers to make the leaves opposite decussate - leaf_rotation = MathConstants.pi / 2.0 - i = 0 - - traverse!(mtg) do node - if symbol(node) == :Internode - # Set to scale, then translate by the total height - mesh_transformation = Meshes.Scale(internode_width, internode_width, internode_length) → Meshes.Translate(0.0, 0.0, internode_height) - node.geometry = PlantGeom.Geometry(ref_mesh=refmesh_internode, transformation=mesh_transformation) - - internode_height += internode_length - - # Leaves are placed relatively to the parent internode, halfway along it - for chnode in children(node) - if symbol(chnode) == :Leaf - mesh_transformation = Meshes.Scale(leaf_scale_width, leaf_scale_width, leaf_scale_height) → Meshes.Rotate(RotX(-MathConstants.pi / 6.0)) → Meshes.Translate(0.0, -internode_width, internode_height - internode_length / 2.0) → Meshes.Rotate(RotZ(leaf_rotation)) - chnode.geometry = PlantGeom.Geometry(ref_mesh=refmesh_leaf, transformation=mesh_transformation) - # Set the second leaf in a pair opposite to the first one => add a 180° rotation - leaf_rotation += MathConstants.pi - end - end - - # Opposite decussate => 90° rotation between pairs - i += 1 - if i % 2 == 0 - leaf_rotation = MathConstants.pi / 2.0 - else - leaf_rotation = MathConstants.pi - end - - elseif symbol(node) == :Root - mesh_transformation = Meshes.Scale(root_width, root_width, root_length) → Meshes.Translate(0.0, 0.0, root_depth) → Meshes.Rotate(RotZ(MathConstants.pi)) - node.geometry = PlantGeom.Geometry(ref_mesh=refmesh_root, transformation=mesh_transformation) - root_depth -= root_length - end - end -end -``` - -And now, let's visualize our fully-grown, fully-featured plant: - -```julia -# Visualize the mesh -using GLMakie -viz(mtg) -``` - -Which gives us the following image scene: - -![Toy Plant with root and leaves](../www/toy_plant.png) - -Feel free to try and make this plant prettier, more colourful, or more physically realistic, using more realistic models on the PlantSimEngine side, or better geometry on the Plantgeom end. \ No newline at end of file diff --git a/docs/src/multiscale/single_to_multiscale.md b/docs/src/multiscale/single_to_multiscale.md deleted file mode 100644 index 7e8af7caf..000000000 --- a/docs/src/multiscale/single_to_multiscale.md +++ /dev/null @@ -1,233 +0,0 @@ -# Converting a single-scale simulation to multi-scale - -```@meta -CurrentModule = PlantSimEngine -``` - -```@setup usepkg -using PlantMeteo, Dates -using PlantSimEngine -using PlantSimEngine.Examples -using MultiScaleTreeGraph -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) -models_singlescale = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2); - status=(TT_cu=cumsum(meteo_day.TT),), -) -``` - -A single-scale simulation can be turned into a 'pseudo-multi-scale' simulation by providing a simple multi-scale tree graph, and declaring a mapping linking all models to a unique scale level. - -This page showcases how to do the conversion, and then adds a model at a new scale to make the simulation genuinely multi-scale. - -The full script for the example can be found in the examples folder, [here](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples/ToySingleToMultiScale.jl) - -```@contents -Pages = ["single_to_multiscale.md"] -Depth = 3 -``` - -# From single to multi-scale mapping - -For example, let's return to the [`ModelMapping`](@ref) coupling a light interception model, a Leaf Area Index model, and a carbon biomass increment model that was discussed in the [Model switching](@ref) subsection: - -```@example usepkg -using PlantMeteo, Dates -using PlantSimEngine -using PlantSimEngine.Examples - -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -models_singlescale = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2); - status=(TT_cu=cumsum(meteo_day.TT),), -) - -outputs_singlescale = run!(models_singlescale, meteo_day) -outputs_singlescale[1:3,:] # show the first 3 rows of the output -``` - -Those models all operate on a simplified model of a single plant, without any organ-local information. We can therefore consider them to be working at the 'whole plant' scale. Their variables also operate at that `:Plant` scale, so there is no need to map any variable to other scales. - -We can therefore convert this into the following mapping: - -```@example usepkg -mapping = ModelMapping( -:Plant => ( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2), - Status(TT_cu=cumsum(meteo_day.TT),) - ), -) -``` - -Note the slight difference in syntax for the [`Status`](@ref). This is because each scale has its own variables, so we must provide the values to each scale independently. - -## Adding a new package for our plant graph - -None of these models operate on a multi-scale tree graph, either. There is no concept of organ creation or growth. We still need to provide a multi-scale tree graph to a multi-scale simulation, so we can -for now- declare a very simple MTG, with a single node: - -```@example usepkg -using MultiScaleTreeGraph - -mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Plant, 0, 0),) -``` - -!!! note - You will need to add the `MultiScaleTreeGraph` package to your environment. See [Installing and running PlantSimEngine](@ref) if you are not yet comfortable with Julia or need a refresher. - -## Running the multi-scale simulation ? - -We now have **almost** everything we need to run the multiscale simulation. - -This first conversion step can be a starting point for a more elaborate multi-scale simulation. - -The signature of the [`run!`](@ref) function in multi-scale differs slightly from the single-scale version: - -```julia -out_multiscale = run!(mtg, mapping, meteo_day) -``` - -(Some of the optional arguments also change slightly) - -Passing in a vector through the [`Status`](@ref) field is still possible in multi-scale mode, but more involving than in single-scale mode. If you need to go this way, you can find a detailed example [here](@ref multiscale_vector), although we don't recommend it for beginners. In any case, it's simpler to write a model to provide the thermal time per timestep as a variable, instead of as a single vector in the [`Status`](@ref). - -Our 'pseudo-multiscale' first approach will therefore turn into a genuine multi-scale simulation. - -## Adding a second scale - -Let's have a model provide the Cumulated Thermal Time to our Leaf Area Index model, instead of initializing it through the [`Status`](@ref). - -Let's instead implement our own `ToyTT_cuModel`. - -### TT_cu model implementation - -This model doesn't require any outside data or input variables, it only operates on the weather data and outputs our desired TT_cu. The implementation doesn't require any advanced coupling and is very straightforward. - -```@example usepkg -PlantSimEngine.@process "tt_cu" verbose = false - -struct ToyTt_CuModel <: AbstractTt_CuModel -end - -function PlantSimEngine.run!(::ToyTt_CuModel, models, status, meteo, constants, extra=nothing) - status.TT_cu += - meteo.TT -end - -function PlantSimEngine.inputs_(::ToyTt_CuModel) - NamedTuple() # No input variables -end - -function PlantSimEngine.outputs_(::ToyTt_CuModel) - (TT_cu=0.0,) -end -``` - -!!! note - The only accessible variables in the [`run!`](@ref) function via the status are the ones that are local to the :Scene scale. This isn't explicit at first glance, but very important to keep in mind when developing models, or using them at different scales. If variables from other scales are required, then they need to be mapped via a [`MultiScaleModel`](@ref), or sometimes a more complex coupling is necessary. - -### Linking the new TT_cu model to a scale in the mapping - -We now have our model implementation. How does it fit into our mapping ? - -Our new model doesn't really relate to a specific organ of our plant. In fact, this model doesn't represent a physiological process of the plant, but rather an environmental process affecting its physiology. We could therefore have it operate at a different scale unrelated to the plant, which we'll call :Scene. This makes sense. - -Note that we now need to add a :Scene node to our Multi-scale Tree Graph, otherwise our model will not run, since no other model calls it and :Plant nodes will only call models at the :Plant scale. See [Empty status vectors in multi-scale simulations](@ref) for more details. - -```@example usepkg -mtg_multiscale = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 0, 0),) - plant = MultiScaleTreeGraph.Node(mtg_multiscale, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) -``` - -### Mapping between scales : the MultiScaleModel wrapper - -The cumulated thermal time (`:TT_cu`) which was previously provided to the LAI model as a simulation parameter now needs to be mapped from the :Scene scale level. - -This is done by wrapping our ToyLAIModel in a dedicated structure called a [`MultiScaleModel`](@ref). A [`MultiScaleModel`](@ref) requires two keyword arguments : `model`, indicating the model for which some variables are mapped, and `mapped_variables`, indicating which scale link to which variables, and potentially renaming them. - -There can be different kinds of variable mapping with slightly different syntax, but in our case, only a single scalar value of the TT_cu is passed from the :Scene to the :Plant scale. - -This gives us the following declaration with the [`MultiScaleModel`](@ref) wrapper for our LAI model: - -```@example usepkg -MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => :Scene, - ], - ) -``` -and the new mapping with two scales: - -```@example usepkg -mapping_multiscale = ModelMapping( - :Scene => ToyTt_CuModel(), - :Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => :Scene, - ], - ), - Beer(0.5), - ToyRUEGrowthModel(0.2), - ), -) -``` - -### Running the multi-scale simulation - -We can then run the multiscale simulation, with our two-node MTG : - -```@example usepkg -outputs_multiscale = run!(mtg_multiscale, mapping_multiscale, meteo_day) -``` - -### Comparing outputs between single- and multi-scale - -The outputs structures are slightly different : multi-scale outputs are indexed by scale, and a variable has a value for every node of the scale it operates at (for instance, there would be a "leaf_surface" value for every leaf in a plant), stored in an array. - -In our simple example, we only have one MTG scene node and one plant node, so the arrays for each variable in the multi-scale output only contain one value. - -We can access the output variables at the :Scene scale by indexing our outputs: - -```@example usepkg -outputs_multiscale[:Scene] -``` -We have a `Vector{NamedTuple}`structure. Our single-scale output is a `Vector{T}`: -```@example usepkg -outputs_singlescale.TT_cu -``` - - Let's extract the multi-scale `:TT_cu`: -```@example usepkg -computed_TT_cu_multiscale = [outputs_multiscale[:Scene][i].TT_cu for i in 1:length(outputs_multiscale[:Scene])] -``` - -We can now compare them value-by-value and do a piecewise approximate equality test : -```@example usepkg -for i in 1:length(computed_TT_cu_multiscale) - if !(computed_TT_cu_multiscale[i] ≈ outputs_singlescale.TT_cu[i]) - println(i) - end -end -``` -or equivalently, with broadcasting, we can write : -```@example usepkg -is_approx_equal = length(unique(computed_TT_cu_multiscale .≈ outputs_singlescale.TT_cu)) == 1 -``` - -!!! note - You may be wondering why we check for approximate equality rather than strict equality. The reason for that is due to floating-point accumulation errors, which are discussed in more detail in [Floating-point considerations](@ref). - -## ToyDegreeDaysCumulModel - -There is a model able to provide Thermal Time based on weather temperature data, [`ToyDegreeDaysCumulModel`](@ref), which can also be found in the examples folder. - -We didn't make use of it here for learning purposes. It also computes a thermal time based on default parameters that don't correspond to the thermal time in the example weather data, so results differ from the thermal time already present in the weather data without tinkering with the parameters. diff --git a/docs/src/planned_features.md b/docs/src/planned_features.md index ba02656e2..814af82d1 100644 --- a/docs/src/planned_features.md +++ b/docs/src/planned_features.md @@ -1,51 +1,20 @@ # Roadmap -This page summarizes work that is still in progress or intentionally left for -future releases. It is not a guarantee of delivery order. - -## Current focus areas - -### Multi-rate MTG simulations - -Model-level varying timesteps are available experimentally for MTG simulations -through mapping-declared multi-rate execution and `ModelSpec` transforms such as -`TimeStepModel`, `InputBindings`, `OutputRouting`, and `ScopeModel`. - -Known gaps in the current implementation: - -- no sub-step execution below the meteorological base-step duration; -- no dedicated event scheduler for irregular or non-fixed calendar execution; -- no threaded or distributed multi-rate MTG execution path yet. - -### Multi-plant and multi-species simulations - -PlantSimEngine can already express multi-scale simulations, but practical support -for scenes containing several plants or several species with overlapping model -sets is still limited. Future work in this area is expected to focus on more -flexible mapping and parameter declaration. - -## API and ergonomics - -- a more consistent mapping API and clearer multiscale dependency declaration; -- improved user-facing errors and diagnostics; -- better dependency graph visualization and traversal helpers; -- broader examples for fitting, type conversion, and error propagation; -- clearer weather-data validation when a simulation requires meteorological inputs. - -## Testing and release engineering - -- broader downstream coverage and better release gating; -- additional checks for memory usage and type stability; -- state-machine or invariant-style validation for runtime outputs; -- graph fuzzing for multiscale corner cases. - -## Lower-priority ideas - -- API support for iterative construction and validation of `ModelMapping`; -- optional code-generation or build steps for validated mappings; -- improved parallel execution strategies; -- reintroducing multi-object parallelism in single-scale runs if the execution - model can stay predictable and testable. - -The full list of open issues is available on +PlantSimEngine now has one composite-model/object runtime for single-object, multiscale, +multi-plant, soil, microclimate, and multirate simulations. + +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; +- validate mutable voxel, layer, and octree microclimate backends; +- expand downstream release gates and performance benchmarks; +- evaluate parallel execution for independent compiled application batches. + +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 d992f7a20..a8358c4fa 100644 --- a/docs/src/prerequisites/installing_plantsimengine.md +++ b/docs/src/prerequisites/installing_plantsimengine.md @@ -1,78 +1,49 @@ -# Installing and running PlantSimEngine +# Installing PlantSimEngine -```@contents -Pages = ["installing_plantsimengine.md"] -Depth = 3 -``` - -This page is meant to help along people newer to Julia. If you are quite accustomed to Julia, installing PlantSimEngine should be par for the course, and you can [move on to the next section](#step_by_step), or read about PlantSimEngine's [Key Concepts](@ref). - -## Installing Julia - -The direct download link can be found [here](https://julialang.org/downloads/), and some additional pointers [in the official manual](https://docs.julialang.org/en/v1/manual/installation/). - -## Installing VSCode - -You can get by using a REPL, but if writing a larger piece of software you may prefer using an IDE. PlantSimEngine is developed using VSCode, which you can install by following instruction [on this page](https://code.visualstudio.com/docs/setup/setup-overview). A documentation section specific to using Julia in VSCode can be found [here](https://code.visualstudio.com/docs/languages/julia). - -## Installing PlantSimEngine and its dependencies - -### Julia environments - -Julia package management is done via the Pkg.jl package. You can find more in-depth sections detailing its usage, and working with Julia environments [in its documentation](https://pkgdocs.julialang.org/v1/) - -If you find this page insufficient to get started, [this tutorial](https://jkrumbiegel.com/pages/2022-08-26-pkg-introduction/) explains in detail the subtleties of Julia environments. - -### Running an environment - -Once your environment is set up, you can launch a command prompt and type `julia`. This will launch Julia, and you should see `julia>` in the command prompt. - -You can always type `?` from there to enter help mode, and type the name of a function or language feature you wish to know more about. - -You can find out which directory you are in by typing `pwd()` in a Julia session. - -Handling environments and dependencies is done in Julia through a specific Package called Pkg, which comes with the base install. You can either call Pkg features the same way you would for another package, or enter Pkg mode by typing `]`, which will change the display from `julia>` to something like `(@v1.11)` pkg>, indicating your current environment (in this case, the default julia environment, which we don't recommend bloating). +Install Julia from the +[official download page](https://julialang.org/downloads/), create a project +environment, and add PlantSimEngine: -Once in Pkg mode, you can choose to create an environment by typing `activate path/to/environment`. - -You can then add packages that have been added to Julia's online global registry by typing `add packagename` and you can remove them by typing `remove packagename`. Typing `status` or `st` will indicate what your current environment is comprised of. To update packages in need of updating (a `^` symbol will display next to their name), type `update`… or `up`. - -If you are editing/developing a package or using one locally, typing `develop path/to/package source/` (or `dev path/to/package/source`) will cause your environment to use that version instead of the registered one. - -Typing `instantiate` will download all the packages declared in the manifest file (if it exists) of an environment. - -For instance, PlantSimEngine has a test folder used in development. If you wanted to run tests, you would type `]` then `activate ../path/to/PlantSimEngine/test` then `instantiate` -and then you would be ready to run some scripts. - -So if you wish to use PlantSimEngine, you can enter Pkg mode (`]`), choose an environment folder, then activate that environment with `activate ../path/to/your_environment`, add PlantSimEngine to it with `add PlantSimEngine` then download the package and its dependencies with `instantiate`. - -### Companion packages - -You'll also, for most of our examples, need `PlantMeteo`. For several multi-scale simulations, you'll need `MultiScaleTreeGraph`. - -Some of the weather data examples make use of the `CSV` package, some output data is manipulated as a DataFrame, which is part of the `DataFrames` package. - -### Using the example models +```julia +using Pkg +Pkg.activate("my_simulation") +Pkg.add("PlantSimEngine") +``` -Example models are exported as a distinct submodule of PlantSimEngine, meaning they aren't part of the main API. You can use them by typing: +Most simulations also use PlantMeteo: ```julia -using PlantSimEngine.Examples +Pkg.add("PlantMeteo") ``` -## Running a test simulation - -Assuming you've setup you're environement, correctly added `PlantMeteo` and `PlantSimEngine` to that environment, and downloaded everything with `instantiate`, you'll be able to run a test example in your REPL by typing line-by-line: +## First Simulation -```@example mypkg +```@example install using PlantSimEngine, PlantMeteo, Dates using PlantSimEngine.Examples -meteo = Atmosphere(T = 20.0, Wind = 1.0, Rh = 0.65, Ri_PAR_f = 500.0) -leaf = ModelMapping(Beer(0.5), status = (LAI = 2.0,)) -out_sim = run!(leaf, meteo) + +meteo = Atmosphere( + T=20.0, + Wind=1.0, + Rh=0.65, + Ri_PAR_f=500.0, + duration=Hour(1), +) + +model = CompositeModel( + Beer(0.5); + status=(LAI=2.0,), + id=:leaf, + scale=:Leaf, + environment=meteo, +) + +run!(model) +only(model_objects(model; scale=:Leaf)).status.aPPFD ``` -## Environments in VSCode +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. -There is detailed documentation explaining how to make use of Julia with VSCode with one section indicating how to handle environments in VSCode: [https://www.julia-vscode.org/docs/stable/userguide/env/](https://www.julia-vscode.org/docs/stable/userguide/env/) - \ No newline at end of file +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 bb22f92ee..707ad6122 100644 --- a/docs/src/prerequisites/julia_basics.md +++ b/docs/src/prerequisites/julia_basics.md @@ -15,7 +15,7 @@ It is not meant as a full-fledged from-scratch Julia tutorial. If you are comple ## Installing packages and setting up and environment For PlantSimEngine, you can check our documentation page on the topic: -[Installing and running PlantSimEngine](@ref) +[Installing PlantSimEngine](installing_plantsimengine.md). ## Cheatsheets @@ -23,8 +23,6 @@ You can also find a few cheatsheets [here](https://palmstudio.github.io/Biophysi ## Troubleshooting -There is a documentation page showcasing some of the common errors than can occur when using PlantSimEngine, which may be worth checking if you are encountering issues: [Troubleshooting error messages](@ref). - 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: @@ -52,4 +50,4 @@ Also of importance: 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. -Understanding more about methods, parametric types and the typing system is usually worthwhile, when working with Julia packages. \ No newline at end of file +Understanding more about methods, parametric types and the typing system is usually worthwhile, when working with Julia packages. diff --git a/docs/src/prerequisites/key_concepts.md b/docs/src/prerequisites/key_concepts.md index 93bef82e6..abef32246 100644 --- a/docs/src/prerequisites/key_concepts.md +++ b/docs/src/prerequisites/key_concepts.md @@ -1,185 +1,100 @@ # Key Concepts -You'll find a brief description of some of the main concepts and terminology related to and used in PlantSimEngine. +## Processes And Models -```@contents -Pages = ["key_concepts.md"] -Depth = 4 -``` - -## Crop models - -## FSPM - -## PlantSimEngine terminology - -This page provides a general description of the concepts and terminology used in PlantSimEngine. For a more implementation-guided description of the design and some of the terms presented here, see the [Detailed walkthrough of a simple simulation](@ref detailed-walkthrough-of-a-simple-simulation) - -!!! Note - Some terminology has different meanings in different contexts. This is particularly true of the terms organ, scale and symbol, which have a different meaning for [Multi-scale Tree Graphs](@ref) than the rest of PlantSimEngine (see [Scale/symbol terminology ambiguity](@ref) further down). Make sure to double-check those subsections, and relevant examples if you encounter issues relating to these terms. - -### Processes - -A process in this package defines a biological or physical phenomena. Think of any process happening in a system, such as light interception, photosynthesis, water, carbon and energy fluxes, growth, yield or even electricity produced by solar panels. - -See [Implementing a new process](@ref) for a brief explanation on how to declare a new process. - -### Models - -A model is a particular implementation for the simulation of a process. - -There may be different models that can be used for the same process; for instance, there are multiple hypotheses and ways of modeling photosynthesis, with different granularity and accuracy. A simple photosynthesis model might apply a simple formula and apply it to the total leaf surface, a more complex one might calculate interception and light extinction. - -!!! note - The companion package PlantBiophysics.jl provides the [`Beer`](https://vezy.github.io/PlantBiophysics.jl/stable/functions/#PlantBiophysics.Beer) structure for the implementation of the Beer-Lambert law of light extinction. The process of `light_interception` and the `Beer` model are provided as an example script in this package too at [`examples/Beer.jl`](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/master/examples/Beer.jl). - -Models can also be used for ad hoc computations that aren't directly tied to a specific literature-defined physiological process. In PlantSimEngine, everything is a model. There are many instances where a custom model might be practical to aggregate some computations or handle other information. To illustrate, XPalm, the Oil Palm model, has a few models that handle the state of different organs, and a model to handle leaf pruning, which you can find [here](https://github.com/PalmStudio/XPalm.jl/blob/main/src/plant/phytomer/leaves/leaf_pruning.jl). - -To prepare a simulation, you declare a ModelMapping with whatever models you wish to make use of and initialize necessary parameters: see the [step by step](@ref detailed-walkthrough-of-a-simple-simulation) section to learn how to use them in practice. - -For multi-scale simulations, models need to be tied to a particular scale when used. See the [Multiscale modeling](@ref) section below, or the [Multi-scale considerations](@ref) page for a more detailed description of multi-scale peculiarities. - -### Variables, inputs, outputs, and model coupling - -A model used in a simulation requires some input data and parameters, and will compute some other data which may be used by other models. Depending on what models are combined in a simulation, some variables may be inputs of some models, outputs of other models, only be part of intermediary computations, or be a user input to the whole simulation. - -Here's a conceptual model coupling; each "node" is equivalent to a distinct PlantSimEngine model, "compute()" is equivalent to the model's "run!" function: - -![Model coupling example](../www/GUID-12E2DDAD-7B20-4FE2-AA36-7FAC950382A6-low.png) -(Source: [Autodesk](https://help.autodesk.com/view/MAYAUL/2016/ENU/?guid=__files_GUID_A9070270_9B5D_4511_8012_BC948149884D_htm")) - -### Dependency graphs - -Coupling models together in this fashion creates what is known as a [Directed Acyclic Graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) or DAG, a type of [dependency graph](https://en.wikipedia.org/wiki/Dependency_graph). The order in which models are run is determined by the ordering of these models in that graph. - -![Example DAG](../www/dags_acyclic_vs_cyclic-d1a669bf1b8b6bfa8ac3041788e81171.png) -A simple Directed Acyclic Graph, note the required absence of cycles. Source: [Astronomer](https://www.astronomer.io/docs/learn/dags/) (Note: "Not Acyclic" is simply "Cyclic"). - -PlantSimEngine creates this Directed Acyclic Graph automatically by plugging the right variables in the right models. Users therefore only need to declare models, they do not need to write the code to connect them as PlantSimEngine does that work for them, as long as the model coupling has no cyclic dependency. - -### ["Hard" and "Soft" dependencies](@id hard_dependency_def) - -Linking models by setting output variables from one model as input of another model handles many typical couplings (with more situations occurring with multi-scale models and variables), but what if two models are interdependent? What if they need to iterate on some computation and pass variables back and forth? - -You can find a typical example in a companion package: [PlantBioPhysics.jl](https://github.com/VEZY/PlantBiophysics.jl). An energy balance model, the [Monteith model](https://github.com/VEZY/PlantBiophysics.jl/blob/master/src/processes/energy/Monteith.jl), needs to [iteratively run a photosynthesis model](https://github.com/VEZY/PlantBiophysics.jl/blob/c1a75f294109d52dc619f764ce51c6ca1ea897e8/src/processes/energy/Monteith.jl#L154) in its [`run!`](@ref) function. - -See the illustration below of the way these models are interdependent: +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. -![Example of a coupling with cycles](../www/ecophysio_coupling_diagram.png) +Models subtype `AbstractModel` and declare: -Example of a coupling with a cycle. Source: PlantBioPhysics.jl +- `inputs_`: values read from object status; +- `outputs_`: values written to object status; +- `meteo_inputs_`: values sampled from the environment; +- `meteo_outputs_`: values written to a mutable environment; +- `dep`: processes called manually by the model, when required. -Model couplings that cause simulation to flow both ways break the 'acyclic' assumption of the dependency graph. - -PlantSimEngine handles this internally by not having those "heavily-coupled" models -called **hard dependencies** from now on- be part of the main dependency graph. Instead, modelers should call these models manually from within a model. This way, they are made to be children nodes of the parent/ancestor model, which handles them internally, so they aren't tied to other nodes of the dependency graph. The resulting higher-level graph therefore only links models without any two-way interdependencies, and remains a directed graph, enabling a cohesive simulation order. The simpler couplings in that top-level graph are called "soft dependencies". - -![Hard dependency coupling visualization in PlantSimEngine](../www/PBP_dependency_graph.png) -The previous coupling, handled by PlantSimEngine - -How PlantSimEngine links these models under the hood. The red models ("hard dependencies") are not exposed in the final dependency graph, which only contains the blue "soft dependencies", and has no cycles. - -This approach does have implications when developing interdependent models: hard dependencies need to be made explicit, and the ancestor needs to call the hard dependency model's [`run!`](@ref) function explicitely in its own [`run!`](@ref) function. Hard dependency models therefore must have only one parent model. - -This reliance on another process makes these models slightly more complex to develop and validate, but keep the versatility of the implementation, as any model implementing the hard-dependency process can be passed by the user. - -Note that hard dependencies can also have their own hard dependencies, and some complex couplings can happen. - -### Weather data - -To run a simulation, we usually need the climatic/meteorological conditions measured close to the object or component. - -Users are strongly encouraged to use [`PlantMeteo.jl`](https://github.com/PalmStudio/PlantMeteo.jl), the companion package that helps manage such data, with default pre-computations and structures for efficient computations. We will make constant use of it throughout the documentation, and recommend working with it. - -The most basic data structure from this package is a type called [`Atmosphere`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.Atmosphere), which defines steady-state atmospheric conditions, *i.e.* the conditions are considered at equilibrium. Another structure is available to define different consecutive time-steps: [`TimeStepTable`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.TimeStepTable). - -The mandatory variables to provide for an [`Atmosphere`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.Atmosphere) are: `T` (air temperature in °C), `Rh` (relative humidity, 0-1) and `Wind` (the wind speed, m s⁻¹). - -In the example below, we also pass in the -optional- incoming photosynthetically active radiation flux (`Ri_PAR_f`, W m⁻²). We can declare such conditions like so: - -```@example usepkg -using PlantMeteo -meteo = Atmosphere(T = 20.0, Wind = 1.0, Rh = 0.65, Ri_PAR_f = 500.0) -``` - -More details are available from the [package documentation](https://vezy.github.io/PlantMeteo.jl/stable). If you do not wish to make use of this package, you can alternately provide your own data, as long as it respects the [Tables.jl interface](https://tables.juliadata.org/stable/#Implementing-the-Interface-(i.e.-becoming-a-Tables.jl-source)) (*e.g.* use a `DataFrame`). - -If you wish to make use of more fine-grained weather data, it will likely require more advanced model creation and MTG manipulation, and more involved work on the modeling side. - -### Organ/Scale - -Plants have different organs with distinct physiological properties and processes. When doing more fine-grained simulations of plant growth, many models will be tied to a particular organ of a plant. Models handling flowering state or root water absorption are such examples. Others, such as carbon allocation and demand, might be reused in slightly different ways for multiple organs of the plant. - -PlantSimEngine documentation tends to use the terms "organ" and "scale" mostly interchangeably. "Scale" is a bit more general and accurate, since some models might not operate at a specific organ level, but (for example) at the scene level, so a "Scene" scale might be present in the MTG, and in the user-provided data. - -When working with multi-scale data, the scale will often need to be specified to map variables, or to indicate at what scale level models work out. You will see some code resembling this : +The numerical kernel is implemented with: ```julia -:Root => (RootGrowthModel(), OrganAgeModel()), -:Leaf => (LightInterceptionModel(), OrganAgeModel()), -:Plant => (TotalBiomassModel(),), +PlantSimEngine.run!(model, models, status, meteo, constants, extra) ``` -This example excerpt links from specific models to a specific scale. Note that one model is reused at two different scales, and note that `:Plant` isn't an actual organ, hence the preferred usage of the term "scale". - -### Multiscale modeling - -Multi-scale modeling is the process of simulating a system at multiple levels of detail simultaneously. Some models might run at the organ scale while others run at the plot scale. Each model can access variables at its scale and other scales if needed, allowing for a more comprehensive system representation. It can also help identify emergent properties that are not apparent at a single level of detail. - -For example, a model of photosynthesis at the leaf scale can be combined with a model of carbon allocation at the plant scale to simulate the growth and development of the plant. Another example is a combination of models to simulate the energy balance of a forest. To simulate it, you need a model for each organ type of the plant, another for the soil, and finally, one at the plot scale, integrating all others. - -When running multi-scale simulations which contain models operating at different organ levels for the plant, extra information needs to be provided by the user to run models. Since some models are reused at different organ levels, it is necessary to indicate which organ level a model operates at. - -This is why multi-scale simulations make use of a 'mapping' in the `ModelMapping`, as well as links between models/variables in different scales, *e.g.* if an input variable comes from another scale, it is required to indicate which scale it is mapped from. - -You can read more about some practical differences as a user between single- and multi-scale simulations here: [Multi-scale considerations](@ref). - -### Multi-scale Tree Graphs - -![Grassy plant and equivalent MTG](../www/Grassy_plant_MTG_vertical.svg) +## Composite Models And Objects -A Grassy plant and its equivalent MTG +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. -Multi-scale Tree Graphs (MTG) are a data structure used to represent plants. A more detailed introduction to the format and its attributes can be found [in the MultiScaleTreeGraph.jl package documentation](https://vezy.github.io/MultiScaleTreeGraph.jl/stable/the_mtg/mtg_concept/). +Objects can carry: -Multi-scale simulations can operate on MTG objects; new nodes are added corresponding to new organs created during the plant's growth. +- a stable identifier; +- scale, kind, species, and name metadata; +- parent-child relationships; +- geometry and position; +- mutable `Status`; +- object-local model applications. -You can see a basic display of an MTG by simply typing its name in the REPL: +`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. -![example display of an MTG in PlantSimEngine](../www/MTG_output.png) +## Model Applications -!!! note - Another companion package, [PlantGeom.jl](https://github.com/VEZY/PlantGeom.jl), can also create MTG objects from .opf files (corresponding to the [Open Plant Format](https://amap-dev.cirad.fr/projects/xplo/wiki/The_opf_format_(*opf)), an alternate means of describing plants computationally). +`ModelSpec` configures one use of a model: -#### Scale/symbol terminology ambiguity +- `AppliesTo(...)` selects target objects; +- `Inputs(...)` selects producers for value dependencies; +- `Calls(...)` binds manually controlled model calls; +- `TimeStep(...)` selects the execution cadence; +- `Environment(...)` configures environment sampling; +- `Updates(...; after=:application_id)` orders intentional additional writers; +- `OutputRouting(...)` controls output publication. -Multi-scale tree graphs have different terminology (see [Organ/Scale](@ref)): +This keeps model implementations generic. Models do not need to know which +model, object, timestep, or coupling scenario will use them. -- the MTG node **symbol** represents "something" like a `:Plant`, `:Root`, `:Scene` or `:Leaf`. It corresponds to a PlantSimEngine *scale* and has nothing to do with the Julia programming language's definition of symbol (*e.g.* `:var`) -- the MTG node **scale**, is an integer passed to the Node constructor, and describes the level of description of the tree graph object. They don't always have a one-to-one correspondence to the symbol (or PlantSimEngine's scale), but are similar. +## Soft And Manual Dependencies -![Three scale levels on an MTG, which differ from typical PlantSimEngine concept of scale](../www/Grassy_plant_scales.svg) +Ordinary dependencies are inferred by matching model inputs with outputs and +are compiled into an acyclic execution order. `Inputs(...)` is used when the +source is cross-object, renamed, temporal, or otherwise ambiguous. -You can find a brief description of the MTG concepts [here](https://vezy.github.io/MultiScaleTreeGraph.jl/stable/the_mtg/mtg_concept/#Node-MTG-and-attributes). +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 `Calls(...)`; the +parent invokes them with `run_call!`. -Other words are unfortunately reused in various contexts with different meanings: tree/leaf/root have a different meaning when talking about computer science data structure (*e.g.*, graphs, dependency graphs and trees). +## Status And References -!!! note - In the majority of cases, you can assume the tree-related terminology refers to the biological terms, and that "organ" refer to plant organs, and "single-scale", "multi-scale" and "scale" to PlantSimEngine's concept of scales described in [Organ/Scale](@ref). MTG objects are mostly manipulated on a per-node basis (the graph node, not the botanical node), unless a model makes use of functions relating to MTG traversal, in which case you may expect computer science terminology. +`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. -#### TLDR +Temporal coupling uses published streams when producer and consumer clocks +differ. Policies include `HoldLast`, `Interpolate`, `Integrate`, `Aggregate`, +and `PreviousTimeStep`. -In summary: +## Environment -- In PlantSimEngine a scale is a level of description defined by a name (`String`). In MTG, a scale is an integer describing the level of description of the node, and a symbol is a name for that node. So symbol in the MTG == scale in PlantSimEngine; -- The word "node" is always used to refer to the Multiscale Tree Graph node, not the botanical node. +The active environment backend may be: -### State machines +- one constant atmosphere shared by all objects; +- a time-indexed weather table; +- a mutable layer, voxel, grid, or octree microclimate. -A state machine is a computational concept used to model mechanisms and devices, which may be of interest for your simulations. +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. -![State machine image](../www/Turnstile_state_machine_colored.svg.png) -A simple state machine. See the [wikipedia page](https://en.wikipedia.org/wiki/Finite-state_machine) for more examples. +## Multiscale Plant Structure -State machines can be useful to model organ state: some organs in [XPalm.jl](https://github.com/PalmStudio/XPalm.jl), a package modelling the oil palm using PlantSimEngine, have a `state` variable behaving like a state machine, indicating whether an organ is mature, pruned, flowering, etc. +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. -You can find an example model (amongst other such models) affecting the `state` variable of some organs depending on their age and thermal time in the XPalm oil palm FSPM [here](https://github.com/PalmStudio/XPalm.jl/blob/main/src/plant/phytomer/phytomer/state.jl). +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 07b81c8bb..a412a8487 100644 --- a/docs/src/step_by_step/advanced_coupling.md +++ b/docs/src/step_by_step/advanced_coupling.md @@ -1,64 +1,168 @@ # Coupling more complex models -```@setup usepkg -using PlantSimEngine, PlantMeteo, Dates -# Import the example models defined in the `Examples` sub-module: +```@setup scene_advanced_coupling +using PlantSimEngine, PlantMeteo, Dates, DataFrames using PlantSimEngine.Examples -m = ModelMapping( - Process1Model(2.0), - Process2Model(), - Process3Model(), - Process4Model(), - Process5Model(), - Process6Model(), - Process7Model(), +meteo_day = read_weather( + joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"); + duration=Dates.Day, ) ``` -When two or more models have a two-way interdependency (rather than variables flowing out only one-way from one model into the next), we describe it as a [hard dependency](@ref hard_dependency_def). +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. + +That second case is a manual call dependency. In the composite-model/object API it is +declared with `Calls(...)`. + +## Soft inputs and manual calls + +Use `Inputs(...)` or inferred same-object bindings when a model only needs a +value. Use `Calls(...)` when the parent model must directly run another model +inside its own `run!` method. + +The example process models in `examples/dummy.jl` contain both patterns: + +- `Process4Model` computes `var1` and `var2`; +- `Process1Model` consumes `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. + +## Declaring manual calls in the scenario + +`Calls(...)` is scenario-level wiring. The model kernel remains generic; the +scenario decides which concrete application is called. + +Use `application=...` in scenario-level `Calls(...)` and `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. + +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. + +```@example scene_advanced_coupling +complex_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene, status=Status(var0=2.0)); + applications=( + ModelSpec(Process4Model(); name=:prepare_inputs) |> + AppliesTo(One(scale=:Scene)) |> + TimeStep(Day(1)), + + ModelSpec(Process1Model(2.0); name=:process1) |> + AppliesTo(One(scale=:Scene)) |> + TimeStep(Day(1)), + + ModelSpec(Process2Model(); name=:process2) |> + AppliesTo(One(scale=:Scene)) |> + Calls(:process1 => One(scale=:Scene, application=:process1)) |> + TimeStep(Day(1)), + + ModelSpec(Process3Model(); name=:process3) |> + AppliesTo(One(scale=:Scene)) |> + Calls(:process2 => One(scale=:Scene, application=:process2)) |> + TimeStep(Day(1)), + + ModelSpec(Process5Model(); name=:process5) |> + AppliesTo(One(scale=:Scene)) |> + TimeStep(Day(1)), + + ModelSpec(Process7Model(); name=:process7) |> + AppliesTo(One(scale=:Scene)) |> + TimeStep(Day(1)), + + ModelSpec(Process6Model(); name=:process6) |> + AppliesTo(One(scale=:Scene)) |> + TimeStep(Day(1)), + ), + environment=meteo_day, +) -This kind of interdependency requires a little more work from the user/modeler for PlantSimEngine to be able to automatically create the dependency graph. +select( + DataFrame(explain_calls(complex_scene)), + :application_id, + :call, + :callee_application_ids, + :callee_object_ids, + :publication_policy, +) +``` -## Declaring hard dependencies +Applications selected by `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. -A model that explicitly and directly calls another process in its [`run!`](@ref) function is part of a hard dependency, or a hard-coupled model. +## Running the coupled model -Let's go through the example processes and models from a script provided by the package here [examples/dummy.jl](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples/dummy.jl) +The regular soft dependencies are still inferred from `inputs_` and +`outputs_`. The scheduler combines those soft edges with the call ownership +rules: -In this script, we declare seven processes and seven models, one for each process. The processes are simply called "process1", "process2"..., and the model implementations are called `Process1Model`, `Process2Model`... +```@example scene_advanced_coupling +select( + DataFrame(explain_schedule(complex_scene)), + :application_id, + :manual_call_only, + :execution_index, + :clock, +) +``` -When run, `Process2Model` calls another process's [`run!`](@ref) function explicitely, which requires defining that process as a hard-dependency of `Process2Model` : +Run one timestep: -```julia -function PlantSimEngine.run!(::Process2Model, models, status, meteo, constants, extra) - # computing var3 using process1: - run!(models.process1, models, status, meteo, constants) - # computing var4 and var5: - status.var4 = status.var3 * 2.0 - status.var5 = status.var4 + 1.0 * meteo.T + 2.0 * meteo.Wind + 3.0 * meteo.Rh -end +```@example scene_advanced_coupling +complex_sim = run!(complex_scene; steps=1) +complex_status = only(model_objects(complex_scene; scale=:Scene)).status +( + var3=complex_status.var3, + var5=complex_status.var5, + var6=complex_status.var6, + var8=complex_status.var8, +) ``` -`Process2Model` is coupled to another process (`process1`), and calls its model's `run` function. The [`run!`](@ref) function is called with the same arguments as the [`run!`](@ref) function of the model that calls it, except that we pass the process we want to simulate as the first argument. - -!!! note - We don't enforce any type of model to simulate `process1`. This is the reason why we can switch so easily between model implementations for any process, by just changing the model in the [`ModelMapping`](@ref). +## Writing new hard-coupled models -A hard-dependency must always be declared to PlantSimEngine. This is done by adding a method to the `dep` function when implementing the model. For example, the hard-dependency to `process1` into `Process2Model` is declared as follows: +For new composite-model/object models, execute all targets directly when they +share meteorology and publication policy: ```julia -PlantSimEngine.dep(::Process2Model) = (process1=AbstractProcess1Model,) +targets = run_call!(extra, :leaf_energy; meteo=meteo, publish=true) ``` -This way PlantSimEngine knows that `Process2Model` needs a model for the simulation of the `process1` process. To avoid imposing a specific model to be coupled with `Process2Model`, the dependency only requires a model that is a subtype of the abstract parent type `AbstractProcess1Model`. This avoids constraining to the specific `Process1Model` implementation, meaning an alternate model computing the same variables for the same process is still interchangeable with `Process1Model`. - -While not encouraged, if you have a valid reason to force the coupling with a particular model, you can force the dependency to require that model specifically. For example, if we want to use only `Process1Model` for the simulation of `process1`, we would declare the dependency as follows: +The result is always vector-like. Retrieve targets without executing them when +an iterative algorithm needs finer control: ```julia -PlantSimEngine.dep(::Process2Model) = (process1=Process1Model,) +function PlantSimEngine.run!(model::SceneEnergyBalance, models, status, meteo, + constants, extra) + for target in call_targets(extra, :leaf_energy) + run_call!(target; meteo=trial_meteo(model, status)) + end + + for target in call_targets(extra, :leaf_energy) + run_call!(target; meteo=accepted_meteo(model, status), publish=true) + end + + return nothing +end ``` -## Examples in the wild +`run_call!` defaults to `publish=false`, which is useful for trial iterations. +Use `publish=true` for the accepted state so temporal streams and mutable +environment outputs 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. -You can find a typical example in a companion package: [PlantBioPhysics.jl](https://github.com/VEZY/PlantBiophysics.jl). An energy balance model, the [Monteith model](https://github.com/VEZY/PlantBiophysics.jl/blob/master/src/processes/energy/Monteith.jl), needs to [iteratively run a photosynthesis model](https://github.com/VEZY/PlantBiophysics.jl/blob/c1a75f294109d52dc619f764ce51c6ca1ea897e8/src/processes/energy/Monteith.jl#L154) in its [`run!`](@ref) function. \ No newline at end of file +Scenario wiring uses `Calls(...)`. Model authors should keep kernels generic +and only require manual calls when the model really needs call-stack control. diff --git a/docs/src/step_by_step/detailed_first_example.md b/docs/src/step_by_step/detailed_first_example.md index 068341b5e..9c1acb90d 100644 --- a/docs/src/step_by_step/detailed_first_example.md +++ b/docs/src/step_by_step/detailed_first_example.md @@ -1,17 +1,21 @@ -# [Detailed walkthrough of a simple simulation](@id detailed-walkthrough-of-a-simple-simulation) +# [Detailed Walkthrough Of A Simple Simulation](@id detailed-walkthrough-of-a-simple-simulation) -This page walks you through the ins and outs of a basic simulation, mostly aimed at people who have less experience programming, to showcase the various concepts presented earlier and requirements for a simulation in context. +This page walks through a small composite-model/object simulation. It is written for +readers who are still getting comfortable with Julia and PlantSimEngine. -A working trimmed-down script can be found further down in the [Example simulation](@ref), and other subsections in this page will detail setup and helper functions, and querying outputs. +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 `AppliesTo(...)`, connect values with `Inputs(...)`, and use +`Calls(...)` when a parent model must manually run child models. -If you simply wish to copy-paste examples and tinker with them, you can find a few examples on the [Quick examples](@ref) page. - -```@setup usepkg -using PlantSimEngine, PlantMeteo, Dates +```@setup detailed_scene +using PlantSimEngine, PlantMeteo, Dates, DataFrames using PlantSimEngine.Examples -meteo = Atmosphere(T = 20.0, Wind = 1.0, Rh = 0.65, Ri_PAR_f = 500.0) -leaf = ModelMapping(Beer(0.5), status = (LAI = 2.0,)) -out_sim = run!(leaf, meteo) + +meteo_day = read_weather( + joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"); + duration=Dates.Day, +) ``` ```@contents @@ -19,226 +23,208 @@ Pages = ["detailed_first_example.md"] Depth = 3 ``` -## Setting up your environment - -For every script in this documentation, you will always need a working Julia environment with PlantSimengine added to it, and usually several other companion packages. Details for getting to that point are provided on the [Installing and running PlantSimEngine](@ref) page. - -## Definitions - -### Processes - -A process in this package defines a biological or physical phenomena. Think of any process happening in a system, such as light interception, photosynthesis, water, carbon and energy fluxes, growth, yield or even electricity produced by solar panels. - -A process is "declared", meaning we define a process, and then implement models for its simulation. In this example, we will make use of a process that was already defined, and for which there already is a model implementation. - -### Models (ModelMapping) - -A process is simulated using a particular implementation, or **a model**. Each model is implemented using a structure that lists the parameters of the model. For example, PlantBiophysics provides the [`Beer`](https://vezy.github.io/PlantBiophysics.jl/stable/functions/#PlantBiophysics.Beer) structure for the implementation of the Beer-Lambert law of light extinction. The process of `light_interception` and the `Beer` model are provided as an example -script in this package too at [`examples/Beer.jl`](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/master/examples/Beer.jl). - -Models can use several types of entries: - -- Parameters -- Meteorological information -- Variables -- Constants -- Extras - -**Parameters** are constant values that are used by the model to compute its outputs, and are exclusive to that model. - -**Meteorological information** contains values that are provided by the user and are used as inputs to the model. It is defined for one time-step, and `PlantSimEngine.jl` takes care of applying the model to each time-steps given by the user. - -**Variables** are either used or computed by the model and can optionally be initialized before the simulation. They can be part of multiple models, computed by one and then used as an input by another. They can also be a global simulation output, or be provided at the start of a simulation by the user. - -**Constants** are constant values, usually common between models, *e.g.* the universal gas constant. - -And **extras** are just extra values that can be used by a model, or serves as a placeholder for internal data. - -Users declare a set of models used for simulation, as well as the necessary parameters for each model, and whatever variables need to be initialized. This is done using a [`ModelMapping`](@ref) structure. - -For example let's instantiate a [`ModelMapping`](@ref) with a single model : the Beer-Lambert model of light extinction, used to simulate the light interception process. The model is implemented with the [`Beer`](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/master/examples/Beer.jl) structure and only has one parameter: the extinction coefficient (`k`). - -Importing the package: - -```@example usepkg -using PlantSimEngine -``` - -Import the examples defined in the [`Examples`](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/main/examples) sub-module (`light_interception` and `Beer`): - -```julia -using PlantSimEngine.Examples -``` +## Setting Up Your Environment -And then declare a [`ModelMapping`](@ref) with the `Beer` model: +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). -```@example usepkg -m = ModelMapping(Beer(0.5)) -``` +## The Simulation Pieces -What happened here? We provided an instance of the `Beer` model to a [`ModelMapping`](@ref) to simulate the light interception process. +### Processes And Models -## Parameters +A process is something you want to simulate, such as light interception, +photosynthesis, water flux, growth, yield, or energy balance. -A parameter is a value constant for a simulation that is internal to a model and used for its computations. For example, the Beer-Lambert model uses the extinction coefficient (`k`) to compute the light extinction. The `Beer` structure in the Beer-Lambert model implementation, only has one field: `k`. We can see that using `fieldnames` on the model structure: +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 usepkg +```@example detailed_scene fieldnames(Beer) ``` -## Variables (inputs, outputs) - -Variables are either inputs or outputs (*i.e.* computed) of models. Variables and their values are stored in the [`ModelMapping`](@ref) structure, and are initialized automatically or manually. - -For example, the `Beer` model needs the leaf area index (`LAI`, m² m⁻²) to run. - -We can see which variables are passed in as inputs using [`inputs`](@ref): +The model implementation declares the status variables it reads and writes: -```@example usepkg +```@example detailed_scene inputs(Beer(0.5)) ``` -and which are computed outputs of the model using [`outputs`](@ref): - -```@example usepkg +```@example detailed_scene outputs(Beer(0.5)) ``` -The [`ModelMapping`](@ref) structure will keep track of every variable's current state when running the simulation, storing them in a field called `status`. We can inspect that field with the [`status`](@ref) function and see that in our example it has two variables: `LAI` and `PPFD`. The first is an input, the second an output (*i.e.* it is computed by the model). +These declarations are the modeler's contract. The composite-model/object layer decides +where the model runs and where those values come from. -```@example usepkg -m = ModelMapping(Beer(0.5)) -keys(status(m)) -``` +### CompositeModel Objects -To know which variables should be initialized, we can use [`to_initialize`](@ref): +A `CompositeModel` contains simulated `Object`s. An object can represent a model, plant, +axis, leaf, soil layer, sensor, voxel, or any other simulated entity. -```@example usepkg -m = ModelMapping(Beer(0.5)) -to_initialize(m) -``` +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. -Their values are uninitialized though (hence the warnings): - -```@example usepkg -(m[:LAI], m[:aPPFD]) +```@example detailed_scene +model = CompositeModel( + Beer(0.5); + status=(LAI=2.0,), + environment=meteo_day, + timestep=Day(1), +) ``` -Uninitialized variables are initialized to the value given in the [`inputs`](@ref) or [`outputs`](@ref) methods in the model's implementation code, which is usually equal to `typemin()`, *e.g.* `-Inf` for `Float64`. +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. -!!! tip - Prefer using [`to_initialize`](@ref) rather than [`inputs`](@ref) to check which variables should be initialized. [`inputs`](@ref) returns every variable that is needed by the model to run, but in multi-model simulations, some of them may already be computed by other models and not require initialization. [`to_initialize`](@ref) returns **only** the variables that are needed by the model to run and that are not initialized in the [`ModelMapping`](@ref). +## Inspecting The Compiled CompositeModel -We can initialize the required variables by providing their starting values to the status when declaring the `ModelMapping`: +Before runtime, PlantSimEngine resolves selectors and builds a compiled model. +This avoids resolving object selections inside the timestep loop. -```@example usepkg -m = ModelMapping(Beer(0.5), status = (LAI = 2.0,)) +```@example detailed_scene +select( + DataFrame(explain_applications(model)), + :application_id, + :process, + :target_ids, +) ``` -Or after instantiation using [`init_status!`](@ref): - -```@example usepkg -m = ModelMapping(Beer(0.5)) +`Beer` has no model-to-model value input in this first model because `LAI` was +initialized directly on the object status: -init_status!(m, LAI = 2.0) +```@example detailed_scene +explain_bindings(model) ``` -We can check if a component is correctly initialized using [`is_initialized`](@ref): +The schedule tells us when each application runs: -```@example usepkg -is_initialized(m) +```@example detailed_scene +select( + DataFrame(explain_schedule(model)), + :application_id, + :dt_seconds, + :root_scheduled, + :manual_call_only, +) ``` -Some variables are inputs of models, but outputs of other models. When we couple models, [`to_initialize`](@ref) only requests the variables that are not computed by other models. - -## Climate forcing +## Running The Simulation -To make a simulation, we usually need the climatic/meteorological conditions measured close to the object or component. +Run the model with [`run!`](@ref): -Users are strongly encouraged to use [`PlantMeteo.jl`](https://github.com/PalmStudio/PlantMeteo.jl), the companion package that helps manage such data, with default pre-computations and structures for efficient computations. The most basic data structure from this package is a type called [`Atmosphere`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.Atmosphere), which defines steady-state atmospheric conditions, *i.e.* the conditions are considered at equilibrium. Another structure is available to define different consecutive time-steps: [`TimeStepTable`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.TimeStepTable). - -The mandatory variables to provide for an [`Atmosphere`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.Atmosphere) are: `T` (air temperature in °C), `Rh` (relative humidity, 0-1) and `Wind` (the wind speed, m s⁻¹). In our example, we also need the incoming photosynthetically active radiation flux (`Ri_PAR_f`, W m⁻²). We can declare such conditions like so: - -```@example usepkg -using PlantMeteo -meteo = Atmosphere(T = 20.0, Wind = 1.0, Rh = 0.65, Ri_PAR_f = 500.0) +```@example detailed_scene +sim = run!(model; steps=3) ``` -This `meteo` variable will therefore provide a single weather timeframe that can be used in a simulation. - -More details are available from the [package documentation](https://vezy.github.io/PlantMeteo.jl/stable). +The object status stores the latest value: -## Simulation - -### Simulation of processes - -To run a simulation, you can call the [`run!`](@ref) method on the [`ModelMapping`](@ref). If some meteorological data is required for models to be simulated over several timesteps, that can be passed in as an optional argument as well. +```@example detailed_scene +scene_status = only(model_objects(model; scale=:Scene)).status +(LAI=scene_status.LAI, aPPFD=scene_status.aPPFD) +``` -Your call to the function would then look like this: +The returned `Simulation` stores retained output streams: -```julia -run!(model_list, meteo) +```@example detailed_scene +first(collect_outputs(sim; sink=nothing), 3) ``` -The first argument is the model mapping (see [`ModelMapping`](@ref)), and the second defines the micro-climatic conditions. +For a table, use the default `DataFrame` sink: -The [`ModelMapping`](@ref) should already be initialized for the given process before calling the function. Refer to the earlier subsection [Variables (inputs, outputs)](@ref) for more details. - -### Example simulation +```@example detailed_scene +first(collect_outputs(sim), 3) +``` -For example we can simulate the `light_interception` of a leaf like so: +## Adding A Model Coupling -```@example usepkg -using PlantSimEngine, PlantMeteo, Dates +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. -# Import the examples defined in the `Examples` sub-module -using PlantSimEngine.Examples +```@example detailed_scene +coupled_scene = CompositeModel( + ToyDegreeDaysCumulModel(), + ToyLAIModel(), + Beer(0.5); + status=(TT_cu=0.0,), + environment=meteo_day, + timestep=Day(1), +) -meteo = Atmosphere(T = 20.0, Wind = 1.0, Rh = 0.65, Ri_PAR_f = 500.0) +select( + DataFrame(explain_bindings(coupled_scene)), + :application_id, + :input, + :source_application_ids, + :carrier_kind, + :copy_semantics, +) +``` -leaf = ModelMapping(Beer(0.5), status = (LAI = 2.0,)) +The `LAI` binding uses a live reference carrier, so the light-interception +model sees the value written by the LAI model without copying it. -outputs_example = run!(leaf, meteo) +Run the coupled model: -outputs_example[:aPPFD] +```@example detailed_scene +coupled_sim = run!(coupled_scene; steps=5, outputs=:all) +first(collect_outputs(coupled_sim), 8) ``` -### Outputs +The final object status contains the latest values from the coupled models: -The [`status`](@ref) field of a [`ModelMapping`](@ref) is used to initialize the variables before simulation and then to keep track of their values during and after the simulation. We can extract outputs of the very last timestep of a simulation using the [`status`](@ref) function. +```@example detailed_scene +coupled_status = only(model_objects(coupled_scene; scale=:Scene)).status +(TT_cu=coupled_status.TT_cu, LAI=coupled_status.LAI, aPPFD=coupled_status.aPPFD) +``` -The actual full output data is returned by the [`run!`](@ref) function. Data is usually stored in a [`TimeStepTable`](@ref) structure from `PlantMeteo.jl`, which is a fast DataFrame-like structure with each time step being a [`Status`](@ref). It can be also be any `Tables.jl` structure, such as a regular `DataFrame`. The weather is also usually stored in a [`TimeStepTable`](@ref) but with each time step being an `Atmosphere`. +## What Needs Initialization? -In our example, the simulation was only provided one weather timestep, so the outputs returned by [`run!`](@ref) and the ModelMapping's [`status`](@ref) field are identical. -Let's look at the outputs structure of our previous simulated leaf: +Model `inputs_(...)` lists every variable a model may need, but not all of +those variables need user initialization. In a coupled model, some inputs are +computed by upstream models. -```@setup usepkg -outputs_example -``` +Use the compiler explanations to distinguish the two cases: -We can extract the value of one variable by indexing into it, *e.g.* for the intercepted light: +- a variable already present on object `Status` is user-provided state; +- a row in `explain_bindings(...)` is compiler-owned coupling; +- a compile error means a required input is neither initialized nor bound. -```@example usepkg -outputs_example[:aPPFD] -``` +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: -Or similarly using the dot syntax: +```@example detailed_scene +bad_scene = CompositeModel( + ToyLAIModel(); + environment=meteo_day, +) -```@example usepkg -outputs_example.aPPFD +try + explain_bindings(bad_scene) +catch err + first(sprint(showerror, err), 300) +end ``` -You can then print the outputs, convert them to another format, or visualize them, using other Julia packages. You can read more on how to do that in the [Visualizing outputs and data](@ref) page. - -Another convenient way to get the results is to transform the outputs into a `DataFrame`. Which is very easy because the [`TimeStepTable`](@ref) implements the Tables.jl interface: +## Migration Note -```@example usepkg -using DataFrames -convert_outputs(outputs_example, DataFrame) -``` +The previous mapping runtime has been removed. Simulations use `CompositeModel`, +`Object`, `ModelSpec`, `AppliesTo`, `Inputs`, `Calls`, `Updates`, `TimeStep`, +and `Environment`. -## Model coupling +See [Migrating To The CompositeModel/Object API](../migration_composite_model.md) for the +translation from the historical mapping API. -A model can work either independently or in conjunction with other models. For example a stomatal conductance model is often associated with a photosynthesis model, *i.e.* it is called from the photosynthesis model. +## Next Steps -`PlantSimEngine.jl` is designed to make model coupling painless for modelers and users. Please see [Standard model coupling](@ref) and [Coupling more complex models](@ref) for more details, or [Handling dependencies in a multiscale context](@ref) for multi-scale specific coupling considerations. +- [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 index afaf888f3..4cb4faa13 100644 --- a/docs/src/step_by_step/implement_a_model.md +++ b/docs/src/step_by_step/implement_a_model.md @@ -41,21 +41,15 @@ end Write the [`run!`](@ref) function that operates on a single timestep : ```@example usepkg -function run!(::Beer, models, status, meteo, constants, extras) - status.PPFD = +function PlantSimEngine.run!(::Beer, models, status, meteo, constants, extra) + status.aPPFD = meteo.Ri_PAR_f * exp(-models.light_interception.k * status.LAI) * constants.J_to_umol + return nothing end ``` -Determine if parallelization is possible, and which traits to declare : - -```@example usepkg -PlantSimEngine.ObjectDependencyTrait(::Type{<:Beer}) = PlantSimEngine.IsObjectIndependent() -PlantSimEngine.TimeStepDependencyTrait(::Type{<:Beer}) = PlantSimEngine.IsTimeStepIndependent() -``` - And that is all you need to get going, for this example with a single parameter and no interdependencies. The [`@process`](@ref) macro does some boilerplate work described [here](@ref under_the_hood) @@ -170,29 +164,38 @@ These functions are internal, and end with an "\_". Users instead use [`inputs`] ### The run! method -When running a simulation with [`run!`](@ref), each model is run in turn at every timestep, following whatever order was deduced from the `ModelMapping` definition and Status. Each model also has its [`run!`](@ref) method for that purpose that update the simulation's current state, with a slightly different signature. The function takes six arguments: +When running a simulation with [`run!`](@ref), each model is run at its +scheduled timestep, following the dependency order compiled from model +applications, inputs, and manual calls. Each model has its own [`run!`](@ref) +method for updating the current state. The function takes six arguments: ```julia -function run!(::Beer, models, status, meteo, constants, extras) +function PlantSimEngine.run!(::Beer, models, status, meteo, constants, extra) ``` - the model's type -- models: a [`ModelMapping`](@ref) object, which contains all the models of the simulation +- models: the process-keyed model bundle compiled from the application and its + `Calls(...)`. - status: a [`Status`](@ref) object, which contains the current values (*i.e.* state) of the variables for **one** time-step (e.g. the value of the plant LAI at time t) - meteo: (usually) an `Atmosphere` object, or a row of the meteorological data, which contains the current values of the meteorological variables for **one** time-step (*e.g.* the value of the PAR at time t) - constants: a `Constants` object, or a `NamedTuple`, which contains the values of the constants for the simulation (*e.g.* the value of the Stefan-Boltzmann constant, unit-conversion constants...) - extras: any other object you want to pass to your model, mostly for advanced usage, not detailed here -A typical [`run!`](@ref) function can therefore make use of simulation constants, input/output variables accessible through the [`Status`](@ref object, or weather data. +A typical [`run!`](@ref) function can therefore use simulation constants, +input/output variables accessible through the [`Status`](@ref) object, or +weather data. -Here is the [`run!`](@ref) implementation of the light interception for a [`ModelMapping`](@ref) component models. Note that the input and output variable are accessed through the [`status`](@ref) argument : +Here is the [`run!`](@ref) implementation of the light interception model. +Note that the input and output variables are accessed through the +`status` argument: ```@example usepkg -function run!(::Beer, models, status, meteo, constants, extras) - status.PPFD = +function PlantSimEngine.run!(::Beer, models, status, meteo, constants, extra) + status.aPPFD = meteo.Ri_PAR_f * exp(-models.light_interception.k * status.LAI) * constants.J_to_umol + return nothing end ``` @@ -203,31 +206,15 @@ To use this model, users will have to make sure that the variables for that mode !!! Note [`Status`](@ref) objects contain the current state of the simulation. It is not, by default, possible to make use of earlier variable states, unless a custom model is written for that purpose. -Model parameters are available from the [`ModelMapping`](@ref) that is passed via the `models` argument. Index by the process name, then the parameter name. For example, the `k` parameter of the `Beer` model is found in `models.light_interception.k`. +Model parameters are available from the process-keyed `models` argument. Index +by the process name, then the parameter name. For example, the `k` parameter of +the `Beer` model is found in `models.light_interception.k`. !!! warning - You need to import all the functions you want to extend, so Julia knows your intention of adding a method to the function from PlantSimEngine, and not defining your own function. To do so, you have to prefix the said functions by the package name, or import them before *e.g.*: `import PlantSimEngine: inputs_, outputs_`. The troubleshooting subsection [Implementing a model: forgetting to import or prefix functions](@ref) showcases output errors that can occur when you forget to prefix. - -### Parallelization traits - -`PlantSimEngine` defines traits to get additional information about the models. At the moment, there are two traits implemented that help the package to know if a model can be run in parallel over space (*i.e.* objects) and/or time (*i.e.* time-steps). - -By default, all models are assumed to be **not** parallelizable over objects and time-steps, because it is the safest default. If your model is parallelizable, you should add the trait to the model. - -For example, if we want to add the trait for parallelization over objects to our `Beer` model, we would do: - -```@example usepkg -PlantSimEngine.ObjectDependencyTrait(::Type{<:Beer}) = PlantSimEngine.IsObjectIndependent() -``` - -And if we want to add the trait for parallelization over time-steps to our `Beer` model, we would do: - -```@example usepkg -PlantSimEngine.TimeStepDependencyTrait(::Type{<:Beer}) = PlantSimEngine.IsTimeStepIndependent() -``` - -!!! note - A model is parallelizable over objects if it does not call another model directly inside its code. Similarly, a model is parallelizable over time-steps if it does not get values from other time-steps directly inside its code. In practice, most of the models are parallelizable one way or another, but it is safer to assume they are not. + Prefix functions you extend with `PlantSimEngine.`, or import them first, + for example `import PlantSimEngine: inputs_, outputs_`. Otherwise Julia + defines an unrelated function in your module instead of adding a method to + PlantSimEngine's function. OK that's it! We now a full new model implementation for the light interception process! Other models might be more complex in terms of what computations they do, or how they couple with other models, but the approach remains the same. @@ -245,4 +232,15 @@ PlantSimEngine.dep(::Fvcb) = (stomatal_conductance=AbstractStomatal_ConductanceM Here we say to PlantSimEngine that the `Fvcb` model needs a model of type `AbstractStomatal_ConductanceModel` in the stomatal conductance process. +This is intentionally process-based because `dep(model)` is a model-author +contract. The model author cannot know which application name a future scenario +will choose for stomatal conductance. In a concrete scenario, users should wire +the selected producer or callee with `application=...` in `Inputs(...)` or +`Calls(...)` when that application is known: + +```julia +ModelSpec(ParentModel(); name=:parent) |> + Calls(:stomata => One(scale=:Leaf, application=:stomatal_conductance)) +``` + You can read more about hard dependencies in [Coupling more complex models](@ref). diff --git a/docs/src/step_by_step/model_switching.md b/docs/src/step_by_step/model_switching.md index c63a31c8f..2be136024 100644 --- a/docs/src/step_by_step/model_switching.md +++ b/docs/src/step_by_step/model_switching.md @@ -1,102 +1,109 @@ # Model switching -```@setup usepkg -using PlantSimEngine, PlantMeteo, Dates -# Import the examples defined in the `Examples` sub-module +```@setup scene_model_switching +using PlantSimEngine, PlantMeteo, Dates, DataFrames using PlantSimEngine.Examples -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -models = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2), - status=(TT_cu=cumsum(meteo_day.TT),), +meteo_day = read_weather( + joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"); + duration=Dates.Day, ) -run!(models, meteo_day) -models2 = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyAssimGrowthModel(), - status=(TT_cu=cumsum(meteo_day.TT),), -) -run!(models2, meteo_day) ``` -One of the main objective of PlantSimEngine is allowing users to switch between model implementations for a given process **without making any change to the PlantSimEngine codebase**. - -The package was designed around this idea to make easy changes easy and efficient. Switch models in the [`ModelMapping`](@ref), and call the [`run!`](@ref) function again. No other changes are required if no new variables are introduced. - -## A first simulation as a starting point - -With a working environment, let's create a [`ModelMapping`](@ref) with several models from the example scripts in the [`examples`](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/master/examples/) folder: - -Importing the models from the scripts: - -```julia -using PlantSimEngine -# Import the examples defined in the `Examples` sub-module: -using PlantSimEngine.Examples +One main objective of PlantSimEngine is to let users switch between model +implementations for a process without changing the engine or the other model +kernels. + +In the composite-model/object API, the switch happens at the model-application layer: +replace the model inside a `ModelSpec`, keep the same `AppliesTo(...)` +selector, and keep the same input contract when the replacement model needs the +same variables. + +## A first simulation + +This model computes degree-days, LAI, absorbed PAR, and growth on one model +object: + +```@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) |> + AppliesTo(One(scale=:Scene)) |> + TimeStep(Day(1)), + + ModelSpec(ToyLAIModel(); name=:lai) |> + AppliesTo(One(scale=:Scene)) |> + TimeStep(Day(1)), + + ModelSpec(Beer(0.5); name=:light_interception) |> + AppliesTo(One(scale=:Scene)) |> + TimeStep(Day(1)), + + ModelSpec(growth_model; name=growth_name) |> + AppliesTo(One(scale=:Scene)) |> + TimeStep(Day(1)), + ), + environment=meteo_day, + ) +end + +rue_scene = plant_model_with_growth(ToyRUEGrowthModel(0.2)) +rue_sim = run!(rue_scene; steps=10) +rue_status = only(model_objects(rue_scene; scale=:Scene)).status +(growth_model=:ToyRUEGrowthModel, biomass=rue_status.biomass) ``` -Coupling the models in a [`ModelMapping`](@ref): - -```@example usepkg -models = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2), - status=(TT_cu=cumsum(meteo_day.TT),), +The compiler infers the same-object bindings from the model declarations. The +growth model reads `aPPFD`, which is produced by the light interception model: + +```@example scene_model_switching +select( + DataFrame(explain_bindings(rue_scene)), + :application_id, + :input, + :source_application_ids, + :origin, + :carrier_kind, ) - -nothing # hide -``` - -We can the simulation by calling the [`run!`](@ref) function with meteorology data. Here we use an example data set: - -```@example usepkg -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) -nothing # hide ``` -We can now run the simulation: - -```@example usepkg -output_initial = run!(models, meteo_day) -output_initial[1:3,:] # show the first 3 rows of the output -``` - -## Switching one model in the simulation - -Now what if we want to switch the model that computes growth ? We can do this by simply replacing the model in the [`ModelMapping`](@ref), and PlantSimEngine will automatically update the dependency graph, and adapt the simulation to the new model. - -Let's switch ToyRUEGrowthModel with ToyAssimGrowthModel: - -```@example usepkg -models2 = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyAssimGrowthModel(), # This was `ToyRUEGrowthModel(0.2)` before - status=(TT_cu=cumsum(meteo_day.TT),), +## Switching the growth model + +`ToyAssimGrowthModel` implements the same `:growth` process, reads the same +`aPPFD` input, and computes additional outputs such as carbon assimilation and +respiration. The rest of the model does not need to change: + +```@example scene_model_switching +assim_scene = plant_model_with_growth(ToyAssimGrowthModel()) +assim_sim = run!(assim_scene; steps=10) +assim_status = only(model_objects(assim_scene; scale=:Scene)).status +( + growth_model=:ToyAssimGrowthModel, + carbon_assimilation=assim_status.carbon_assimilation, + Rm=assim_status.Rm, + biomass=assim_status.biomass, ) - -nothing # hide ``` -ToyAssimGrowthModel is a little bit more complex than `ToyRUEGrowthModel`](@ref), as it also computes the maintenance and growth respiration of the plant, so it has more parameters (we use the default values here). - -We can run a new simulation and see that the simulation's results are different from the previous simulation: +The dependency graph and execution plan are rebuilt from the new application +set: -```@example usepkg -output_updated = run!(models2, meteo_day) -output_updated[1:3,:] # show the first 3 rows of the output +```@example scene_model_switching +select( + DataFrame(explain_execution_plan(assim_sim)), + :application_id, + :object_ids, + :batch_size, + :inner_loop_dispatch, +) ``` -And that's it! We can switch between models without changing the code, and without having to recompute the dependency graph manually. This is a very powerful feature of PlantSimEngine!💪 - -!!! note - This was a very standard but straightforward example. Sometimes other models will require to add other models to the [`ModelMapping`](@ref). For example ToyAssimGrowthModel could have required a maintenance respiration model. In this case `PlantSimEngine` will indicate what kind of model is required for the simulation. - -!!! note - In our example we replaced what we call a [soft-dependency coupling](@ref hard_dependency_def), but the same principle applies to [hard-dependencies](@ref hard_dependency_def). Hard and Soft dependencies are concepts related to model coupling, and are discussed in more detail in [Standard model coupling](@ref) and [Coupling more complex models](@ref). +This is the same principle used in larger composite models: switch one process +implementation by replacing one `ModelSpec` or by using an +`ObjectInstance(...; overrides=...)` when the change applies to one plant +instance or one organ. +The removed mapping runtime expressed the same idea differently. New scenario +code expresses model switching through model model applications. diff --git a/docs/src/step_by_step/parallelization.md b/docs/src/step_by_step/parallelization.md deleted file mode 100644 index 6c590059b..000000000 --- a/docs/src/step_by_step/parallelization.md +++ /dev/null @@ -1,39 +0,0 @@ -## Parallel execution - -!!! note - This page is likely to change and become outdated. In any case, parallel execution only currently applies to single-scale simulations (multi-scale simulations' changing MTGs and extra complexity don't allow for straightforward parallelisation) - -### FLoops - -`PlantSimEngine.jl` uses the [`Floops`](https://juliafolds.github.io/FLoops.jl/stable/) package to run the simulation in sequential, parallel (multi-threaded) or distributed (multi-process) computations over objects, time-steps and independent processes. - -That means that you can provide any compatible executor to the `executor` argument of [`run!`](@ref). By default, [`run!`](@ref) uses the [`ThreadedEx`](https://juliafolds.github.io/FLoops.jl/stable/reference/api/#executor) executor, which is a multi-threaded executor. You can also use the [`SequentialEx`](https://juliafolds.github.io/Transducers.jl/dev/reference/manual/#Transducers.SequentialEx)for sequential execution (non-parallel), or [`DistributedEx`](https://juliafolds.github.io/Transducers.jl/dev/reference/manual/#Transducers.DistributedEx) for distributed computations. - -### Parallel traits - -`PlantSimEngine.jl` uses [Holy traits](https://invenia.github.io/blog/2019/11/06/julialang-features-part-2/) to define if a model can be run in parallel. -See also [Model traits](../model_traits.md) for a full inventory of model-level traits. - -!!! note - A model is executable in parallel over time-steps if it does not uses or set values from other time-steps, and over objects if it does not uses or set values from other objects. - -You can define a model as executable in parallel by defining the traits for time-steps and objects. For example, the ToyLAIModel model from the [examples folder](https://github.com/VirtualPlantLab/PlantSimEngine.jl/tree/main/examples) can be run in parallel over time-steps and objects, so it defines the following traits: - -```julia -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyLAIModel}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyLAIModel}) = PlantSimEngine.IsObjectIndependent() -``` - -By default all models are considered not executable in parallel, because it is the safest option to avoid bugs that are difficult to catch, so you only need to define these traits if it is executable in parallel for them. - -!!! tip - A model that is defined executable in parallel will not necessarily will. First, the user has to pass a parallel `executor` to [`run!`](@ref) (*e.g.* `ThreadedEx`). Second, if the model is coupled with another model that is not executable in parallel, `PlantSimEngine` will run all models in sequential. - -### Further executors - -You can also take a look at [FoldsThreads.jl](https://github.com/JuliaFolds/FoldsThreads.jl) for extra thread-based executors, [FoldsDagger.jl](https://github.com/JuliaFolds/FoldsDagger.jl) for -Transducers.jl-compatible parallel fold implemented using the Dagger.jl framework, and soon [FoldsCUDA.jl](https://github.com/JuliaFolds/FoldsCUDA.jl) for GPU computations -(see [this issue](https://github.com/VirtualPlantLab/PlantSimEngine.jl/issues/22)) and [FoldsKernelAbstractions.jl](https://github.com/JuliaFolds/FoldsKernelAbstractions.jl). You can also take a look at -[ParallelMagics.jl](https://github.com/JuliaFolds/ParallelMagics.jl) to check if automatic parallelization is possible. - -Finally, you can take a look into [Transducers.jl's documentation](https://github.com/JuliaFolds/Transducers.jl) for more information, for example if you don't know what is an executor, you can look into [this explanation](https://juliafolds.github.io/Transducers.jl/stable/explanation/glossary/#glossary-executor). diff --git a/docs/src/step_by_step/quick_and_dirty_examples.md b/docs/src/step_by_step/quick_and_dirty_examples.md index e871e8518..9d3b17f4b 100644 --- a/docs/src/step_by_step/quick_and_dirty_examples.md +++ b/docs/src/step_by_step/quick_and_dirty_examples.md @@ -1,95 +1,127 @@ -# Quick examples +# Quick Examples -This page is meant for people who have set up their environment and just want to copy-paste an example or two, see what the REPL returns and start tinkering. +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). -If you are less comfortable with Julia, or need to set up an environment first, see this page : [Getting started with Julia](@ref). -If you wish for a more detailed rundown of the examples, you can instead have a look at the [step by step](#step_by_step) section, which will go into more detail. +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 `AppliesTo(...)` and `Inputs(...)`. -These examples are all for single-scale simulations. For multi-scale modelling tutorials and examples, refer to [this section][#multiscale] +```@setup quick_model_examples +using PlantSimEngine, PlantMeteo, Dates, DataFrames +using PlantSimEngine.Examples -You can find the implementation for all the example models, as well as other toy models [in the examples folder](https://github.com/VirtualPlantLab/PlantSimEngine.jl/tree/main/examples). +meteo_day = read_weather( + joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"); + duration=Dates.Day, +) +``` ```@contents Pages = ["quick_and_dirty_examples.md"] Depth = 2 ``` -## Environment - -These examples assume you have a working Julia environment with PlantSimengine added to it, as well as the other packages used in these examples. Details for getting to that point are provided on the [Installing and running PlantSimEngine](@ref) page. - +## One Light Interception Model -## Example with a single light interception model and a single weather timestep +```@example quick_model_examples +model = CompositeModel( + Beer(0.5); + status=(LAI=2.0,), + environment=meteo_day, +) -```@example usepkg -using PlantSimEngine, PlantMeteo, Dates -using PlantSimEngine.Examples -meteo = Atmosphere(T = 20.0, Wind = 1.0, Rh = 0.65, Ri_PAR_f = 500.0) -leaf = ModelMapping(Beer(0.5), status = (LAI = 2.0,)) -out = run!(leaf, meteo) +sim = run!(model; steps=3, outputs=:all) +first(collect_outputs(sim), 3) ``` -## Coupling the light interception model with a Leaf Area Index model - -The weather data in this example contains data over 365 days, meaning the simulation will have as many timesteps. - -```@example usepkg -using PlantSimEngine -using PlantMeteo, Dates -using PlantSimEngine.Examples +## LAI And Light Interception -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) +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. -models = ModelMapping( +```@example quick_model_examples +lai_scene = CompositeModel( + ToyDegreeDaysCumulModel(), ToyLAIModel(), - Beer(0.5), - status=(TT_cu=cumsum(meteo_day.TT),), + Beer(0.5); + environment=meteo_day, ) -outputs_coupled = run!(models, meteo_day) -outputs_coupled[1:3,:] # show the first 3 rows of the output +lai_sim = run!(lai_scene; steps=5, outputs=:all) +first(collect_outputs(lai_sim), 8) ``` -## Coupling the light interception and Leaf Area Index models with a biomass increment model +Inspect the inferred coupling: +```@example quick_model_examples +select( + DataFrame(explain_bindings(lai_scene)), + :application_id, + :input, + :source_application_ids, + :carrier_kind, +) +``` -```@example usepkg -using PlantSimEngine -using PlantMeteo, Dates -using PlantSimEngine.Examples +## Add Biomass Growth -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) +`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. -models = ModelMapping( +```@example quick_model_examples +growth_scene = CompositeModel( + ToyDegreeDaysCumulModel(), ToyLAIModel(), Beer(0.5), - ToyRUEGrowthModel(0.2), - status=(TT_cu=cumsum(meteo_day.TT),), + ToyRUEGrowthModel(0.2); + environment=meteo_day, ) -outputs_coupled = run!(models, meteo_day) -outputs_coupled[1:3,:] # show the first 3 rows of the output +growth_sim = run!(growth_scene; steps=5) +growth_status = only(model_objects(growth_scene; scale=:Scene)).status +(LAI=growth_status.LAI, aPPFD=growth_status.aPPFD, biomass=growth_status.biomass) ``` -## Example using PlantBioPhysics +## Keep Only One Requested Output -A companion package, PlantBioPhysics, uses PlantSimEngine, and contains other models used in ecophysiological simulations. +For larger simulations, request only the streams you want to keep: -You can have a look at its documentation [here](https://vezy.github.io/PlantBiophysics.jl/stable/) +```@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) +``` -Several example simulations are provided there. Here's one taken from [this page](https://vezy.github.io/PlantBiophysics.jl/stable/simulation/first_simulation/) : +## PlantBiophysics -```julia -using PlantBiophysics, PlantSimEngine +The same composite-model/object API can host models from companion packages such as +PlantBiophysics. A typical PlantBiophysics energy-balance setup uses +`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. -meteo = Atmosphere(T = 22.0, Wind = 0.8333, P = 101.325, Rh = 0.4490995) +See [MAESPA-style model example handoff](../dev/maespa_model_handoff.md) for +the current multi-plant energy-balance acceptance example. -leaf = ModelMapping( - Monteith(), - Fvcb(), - Medlyn(0.03, 12.0), - status = (Ra_SW_f = 13.747, sky_fraction = 1.0, aPPFD = 1500.0, d = 0.03) - ) +## Migration Note -out = run!(leaf,meteo) -``` \ No newline at end of file +The previous mapping runtime has been removed. Simulations start from `CompositeModel`, +`Object`, `ModelSpec`, `AppliesTo`, `Inputs`, `Calls`, `Updates`, `TimeStep`, +and `Environment`. diff --git a/docs/src/step_by_step/simple_model_coupling.md b/docs/src/step_by_step/simple_model_coupling.md index 179b36d6f..eaed4da89 100644 --- a/docs/src/step_by_step/simple_model_coupling.md +++ b/docs/src/step_by_step/simple_model_coupling.md @@ -1,127 +1,101 @@ # Standard model coupling -```@setup usepkg +```@setup scene_coupling using PlantSimEngine using PlantSimEngine.Examples -using PlantMeteo, Dates +using PlantMeteo, Dates, DataFrames -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) -models = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2), - status=(TT_cu=cumsum(meteo_day.TT),), +meteo_day = read_weather( + joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"); + duration=Dates.Day, ) -nothing ``` -## Setting up your environment - -Again, make sure you have a working Julia environment with PlantSimengine added to it, and the other recommended companion packages. Details for getting to that point are provided on the [Installing and running PlantSimEngine](@ref) page. +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. -## ModelMapping - -The [`ModelMapping`](@ref) is a container that holds a list of models, their parameter values, and the status of the variables associated to them. - -If one looks at prior examples, the ModelMappings so far have only contained a single model, whose input variables are initialised in the ModelMapping [`status`](@ref) keyword argument. - -Example models are all taken from the example scripts in the [`examples`](https://github.com/VirtualPlantLab/PlantSimEngine.jl/blob/master/examples/) folder. +## Setting up your environment -Here's a first [`ModelMapping`](@ref) declaration with a light interception model, requiring input Leaf Area Index (LAI): +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. -```julia -modellist_coupling_part_1 = ModelMapping(Beer(0.5), status = (LAI = 2.0,)) -``` +## One object and one model -Here's a second one with a Leaf Area Index model, with some example Cumulated Thermal Time as input. (This TT_cu is usually computed from weather data): +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: -```julia -modellist_coupling_part_2 = ModelMapping( - ToyLAIModel(), - status=(TT_cu=1.0:2000.0,), # Pass the cumulated degree-days as input to the model +```@example scene_coupling +light_scene = CompositeModel( + Beer(0.5); + status=(LAI=2.0,), + environment=meteo_day, ) -``` - -## Combining models -Suppose we want our ToyLAIModel to compute the `LAI` for the light interception model. - -We can couple the two models by having them be part of a single [`ModelMapping`](@ref). The `LAI` variable will then be a coupled output computed by the ToyLAIModel, then used as input by `Beer`. It will no longer need to be declared as part of the [`status` . +light_sim = run!(light_scene; steps=3, outputs=:all) +first(collect_outputs(light_sim; sink=DataFrame), 3) +``` -This is an instance of what we call a ["soft dependency" coupling](@ref hard_dependency_def): a model depends on another model's outputs for its inputs. +## Coupling two models -Here's a first attempt : +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 usepkg -using PlantSimEngine -# Import the examples defined in the `Examples` sub-module: -using PlantSimEngine.Examples - -# A ModelMapping with two coupled models -models = ModelMapping( +```@example scene_coupling +coupled_scene = CompositeModel( + ToyDegreeDaysCumulModel(), ToyLAIModel(), - Beer(0.5), - status=(TT_cu=1.0:2000.0,), + Beer(0.5); + environment=meteo_day, ) -struct UnexpectedSuccess <: Exception end #hack to enable checking an error without failing docbuild #hide -# see https://github.com/JuliaDocs/Documenter.jl/issues/1420 #hide -try #hide -run!(models) -throw(UnexpectedSuccess()) #hide -catch err; err isa UnexpectedSuccess ? rethrow(err) : showerror(stderr, err); end #hide -``` - -Oops, we get an error related to the weather data, with the detailed output being: -```julia -ERROR: type NamedTuple has no field Ri_PAR_f -Stacktrace: - [1] getindex(mnt::Atmosphere{(), Tuple{}}, i::Symbol) - @ PlantMeteo ~/Path/to/PlantMeteo/src/structs/atmosphere.jl:147 - [2] getcolumn(row::PlantMeteo.TimeStepRow{Atmosphere{(), Tuple{}}}, nm::Symbol) - @ PlantMeteo ~/Path/to/PlantMeteo/src/structs/TimeStepTable.jl:205 - ... +select( + DataFrame(explain_bindings(coupled_scene)), + :application_id, + :input, + :source_application_ids, + :origin, + :carrier_kind, + :copy_semantics, +) ``` -The `Beer` model requires a specific meteorological parameter. Let's fix that by importing the example weather data : +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. -```@example usepkg -using PlantSimEngine +Run the coupled model: -# PlantMeteo and CSV packages are now used -using PlantMeteo, Dates +```@example scene_coupling +coupled_sim = run!(coupled_scene; steps=5) +coupled_status = only(model_objects(coupled_scene; scale=:Scene)).status +(TT_cu=coupled_status.TT_cu, LAI=coupled_status.LAI, aPPFD=coupled_status.aPPFD) +``` -# Import the examples defined in the `Examples` sub-module: -using PlantSimEngine.Examples +## Adding another model -# Import example weather data -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) +Additional models are just additional applications. `ToyRUEGrowthModel` +consumes `aPPFD`, which is produced by `Beer`, so the compiler infers another +same-object binding: -# A ModelMapping with two coupled models -models = ModelMapping( +```@example scene_coupling +growth_scene = CompositeModel( + ToyDegreeDaysCumulModel(), ToyLAIModel(), Beer(0.5), - status=(TT_cu=cumsum(meteo_day.TT),), # We can now compute a genuine cumulative thermal time from the weather data + ToyRUEGrowthModel(0.2); + environment=meteo_day, ) -# Add the weather data to the run! call -outputs_coupled = run!(models, meteo_day) -outputs_coupled[1:3,:] +growth_sim = run!(growth_scene; steps=5) +growth_status = only(model_objects(growth_scene; scale=:Scene)).status +(LAI=growth_status.LAI, aPPFD=growth_status.aPPFD, biomass=growth_status.biomass) ``` -And there you have it. The light interception model made its computations using the Leaf Area Index computed by ToyLAIModel. - -## Further coupling - -Of course, one can keep adding models. Here's an example `ModelMapping` with another model, `ToyRUEGrowthModel`, which computes the carbon biomass increment caused by photosynthesis. - -```julia -models = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2), - status=(TT_cu=cumsum(meteo_day.TT),), -) - -nothing # hide -``` \ No newline at end of file +Older examples used the removed mapping runtime for this workflow. New +scenarios start from `CompositeModel`, `Object`, `ModelSpec`, `AppliesTo`, `Inputs`, +`Calls`, and `TimeStep`. diff --git a/docs/src/troubleshooting/common_errors.md b/docs/src/troubleshooting/common_errors.md new file mode 100644 index 000000000..3ce8ee12a --- /dev/null +++ b/docs/src/troubleshooting/common_errors.md @@ -0,0 +1,13 @@ +# Common Errors + +A missing-input error means neither supplied state, a unique producer, nor an +environment binding exists; start with `explain_initialization`. 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. + diff --git a/docs/src/troubleshooting/dependency_cycles.md b/docs/src/troubleshooting/dependency_cycles.md new file mode 100644 index 000000000..e3c4f7d6a --- /dev/null +++ b/docs/src/troubleshooting/dependency_cycles.md @@ -0,0 +1,26 @@ +# 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. + +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: + +```julia +Inputs( + PreviousTimeStep(:water) => + One(scale=:Root, application=:root, var=:water), +) +``` + +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. diff --git a/docs/src/troubleshooting/runtime_contracts.md b/docs/src/troubleshooting/runtime_contracts.md new file mode 100644 index 000000000..c22f33485 --- /dev/null +++ b/docs/src/troubleshooting/runtime_contracts.md @@ -0,0 +1,11 @@ +# Runtime Contracts And Diagnostics + +Use `explain_initialization`, `explain_bindings`, `explain_calls`, +`explain_schedule`, `explain_environment_bindings`, and +`explain_output_retention` as the supported inspection surface. Do not inspect +compiled internal fields. + +Targets, carriers, calls, writer checks, and schedules refresh after structural +changes between timesteps. Movement and geometry changes invalidate affected +spatial environment bindings. Accepted streams are append-only. + diff --git a/docs/src/troubleshooting_and_testing/implicit_contracts.md b/docs/src/troubleshooting_and_testing/implicit_contracts.md deleted file mode 100644 index b65b4a9da..000000000 --- a/docs/src/troubleshooting_and_testing/implicit_contracts.md +++ /dev/null @@ -1,96 +0,0 @@ -This page summarizes some of the assumptions, coupling constraints and inner workings of PlantSimEngine which may be particular relevant when implementing new models. - -If you are unsure of an implementation subtlety, check this page out to see whether it answers your question. - -```@contents -Pages = ["implicit_contracts.md"] -Depth = 2 -``` - -## Weather data provides the simulation timestep, but models can veer away from it - -The weather data timesteps, whether hourly or daily, provide the pace at which most other models run. - -In XPalm, weather data for most models is provided daily, meaning biomass calculations are also provided daily. - -Many models are considered to be steady-state over that timeframe, but not all : the leaf pruning model pertubes the plant in a non-steady state fashion, for example. Models that require computations over several iterations to stabilise (often part of hard dependencies) might also have a timestep unrelated to the weather data. - -!!! Note - Implicitely, this means any vector variables given as input to the simulation must be consistent with the number of weather timesteps. Providing one weather value but a larger vector variable is an exception : the weather data is replicated over each timestep. (This may be subject to change in the future when support for different timesteps in a single simulation is implemented) - -## Why does my model skip half-hour rows? - -If your meteo has 30-minute rows but a model appears to run hourly, check timestep resolution order: - -1. If model has explicit `TimeStepModel(...)`, it is used. -2. Else if model `timespec(model)` is non-default, it is used. -3. Else model uses meteo `duration`. - -Then compatibility rules apply: - -1. `timestep_hint.required` is enforced for meteo-derived clocks. -2. `timestep_hint.preferred` is informational only. -3. Meteo aggregation/integration happens only for models with coarser effective clocks. - -Common cause: -- model has explicit hourly `TimeStepModel(...)`, so 30-minute rows are intentionally aggregated to hourly runs. - -Quick diagnostics: -- Run `explain_model_specs(mapping_or_sim)` to see, per process, whether runtime clock comes from explicit `ModelSpec`, model `timespec`, or meteo base step. -- Ensure meteo `duration` is present and valid on every row (mandatory when meteo is provided). - -## Weather data must be interpolated prior to simulation - -If your weather data isn't adjusted to conform to a regular timestep, you will need to adjust it to fit that constraint. PlantSimEngine does no interpolation prior to simulation and expects regular weather timesteps. - -## No cyclic dependencies in the simplified dependency graph - -The model dependency graph used for running the simulation is comprised of soft and hard dependency nodes, and the final version only links soft dependency nodes together, and is expected to contain no cycles. - -Any user model coupling which causes a cyclic dependency to occur will require some extra tinkering to run : either design models differently, create a hard dependency with some of the problematic models, or break the cycle by having a variable take the previous timestep's value as input. - -See [Dependency graphs](@ref) and the following subsections for more discussion related to dependency graph constraints. - -Note : Only the previous timestep is accessible in PlantSimEngine without any kind of dedicated model. How to create a model to store more past timesteps of a specific variable is described in the [Tips and workarounds](@ref) page: [Making use of past states in multi-scale simulations](@ref) - -## Hard dependencies need to be declared in the model definition - -Hard dependencies are handled internally by their owning soft dependency model, ie the hard dep's run! function is directly called by the soft dependency's run!. - -The current way in which PlantSimEngine creates its dependency graph requires users to declare what process is required in the hard dependency and which scale it pulls the model and its variables from. - -## Parallelisation opportunities must be part of the model definition - -Traits that indicate that a model is independent or objects need to be part of the model definition. Modelers need to keep this in mind when implementing new models. - -This is currently mostly a concern for single-scale simulations, as multi-scale simulations are not currently parallelised ; a more involved scheduler would need to be implemented when MTGs are modified by models, and to handle more interesting parallelisation opportunities at specific scales. - -There may be new parallelisation features for multi-plant simulations further down the road. - -## Hard dependencies can only have one parent in the dependency graph - -The final dependency graph is comprised only of soft dependency nodes, and is guaranteed to contain no cycles. Hard dependencies are handled internally by their soft dependency ancestor. To avoid any ambiguity in terms of processing order, only one soft dependency node can 'own' a hard dependency And similarly, nested hard dependencies only have a single soft dependency ancestor. - -This is not solely an implementation detail of PlantSimEngine's internal mechanisms ; if your simulation requires complex coupling, you might need to carefully consider how to manage your hard dependencies, or insert an extra intermediate model to simplify things. - -## A model can only be used once per scale - -Similarly, to avoid depedency graph ambiguity (and for simulation cohesion), PlantSimEngine currently assumes a model describing a process only occurs once per scale. - -Model renaming and duplicating works around this assumption. It may change once multi-plant/multi-species features are implemented. - -## No two variables with the same name at the same scale - -This rule avoids potential ambiguity which could then cause both problems in terms of model ordering during the simulation, as well as incorrectly coupling models with the wrong variable. - -A workaround for some of the situations where this occurs is described here : [Having a variable simultaneously as input and output of a model](@ref) - -## Simulation order instability when adding models - -An important aspect to bear in mind is that PlantSimEngine automatically determines an order in which models are run from the dependency graph it generates by coupling models together. - -This order of simulation depends on the way the models link together. If you replace a model by a new set of models, or pass in new variables that create new links between models, you may change the simulation order. - -When iterating and slowly making a simulation more physiologically realistic and complex, it is therefore fully possible that the order in which two models are run is flipped by a user change. - -This design choice implementation -a concession made for ease of use and flexibility when developing a simulation- means that until your set of models is fully stabilized and you know which variables are `PreviousTimestep` and what order models run in, as you expand and change the set you might see differences of execution of one timestep for some models. It isn't a conceptual problem as most models are steady-state, and simulation order is stable for a given set of models, but it does mean PlantSimEngine will be less conveient for some types of simulation. diff --git a/docs/src/troubleshooting_and_testing/plantsimengine_and_julia_troubleshooting.md b/docs/src/troubleshooting_and_testing/plantsimengine_and_julia_troubleshooting.md deleted file mode 100644 index 602045e04..000000000 --- a/docs/src/troubleshooting_and_testing/plantsimengine_and_julia_troubleshooting.md +++ /dev/null @@ -1,515 +0,0 @@ -# Troubleshooting error messages - -PlantSimEngine attempts to be as comfortable and easy to use as possible for the user, and many kinds of user error will be caught and explanations provided to resolve them, but there are still blind spots, as well as syntax errors that will often generate a Julia error (which can be less intuitive to decrypt) rather than a PlantSimEngine error. - -To help people newer to Julia with troubleshooting, here are a few common 'easy-to-make' mistakes with the current API that might not be obvious to interpret, and pointers on how to fix them. - -They are listed by 'nature of error', rather than by error message, so you may need to search the page to find your specific error. - -If you need more help to decode Julia errors, you can find help on the [Julia Discourse forums](https://discourse.julialang.org). -If you need some advice on the FSPM side, the research community has [its own discourse forum](https://fspm.discourse.group). - -If the issue seems PlantSimEngine-related, or you have questions regarding modeling or have suggestions, you can also [file an issue](https://github.com/VirtualPlantLab/PlantSimEngine.jl/issues) on Github. - -```@contents -Pages = ["plantsimengine_and_julia_troubleshooting.md"] -Depth = 3 -``` - -## Tips and workflow - -Some errors are very specific as to their cause, and the PlantSimEngine errors tend to be explicit about which parameter / variable / organ is causing the error, helping narrow down its origin. - -Some generic-looking errors usually do contain some extra information to help focus the debugging hunt. For instance, a dispatch failure on run! caused by some issue with args/kwargs may highlight explicitely indicate which arguments are currently causing conflict. In VSCode, such arguments are highlighted in red (the first and last arguments in the example below): - -```julia -a = 1 -run!(a, simple_mtg, mapping, meteo_day, a) - -ERROR: MethodError: no method matching run!(::Int64, ::Node{NodeMTG, Dict{…}}, ::Dict{String, Tuple{…}}, ::DataFrame, ::Int64) -The function [`run!`](@ref) exists, but no method is defined for this combination of argument types. - -Closest candidates are: - run!(::ToyPlantLeafSurfaceModel, ::Any, ::Any, ::Any, ::Any, ::Any) - @ PlantSimEngine /PlantSimEngine/examples/ToyLeafSurfaceModel.jl:75 - ... -``` - -If you wish to search for a specific error in the current page, copy the part of the description that is not specific to your script, and Ctrl+F it here. In the above example, the generic part would be : -```julia -ERROR: MethodError: no method matching -``` - -## Common Julia errors - -### NamedTuples with a single value require a comma : - -This one is easy to miss. - -Empty NamedTuple objects are initialised with x = NamedTuple(). Ones with more than one variable can be initialised like this : -```julia -a = (var1 = 0, var2 = 0) -``` -or like this : -```julia -a = (var1 = 0, var2 = 0,) -``` -The second comma being optional. - -However, if there is only a single variable, notation has to be : -```julia -a = (var1 = 0,) -``` -The comma is compulsory. If it is forgotten : -```julia -a = (var1 = 0) -``` -the line will be interpreted as setting the variable a to the value var1 is set to, hence a will be an Int64 of value 0. - -This is a liability when writing custom models as some functions work with NamedTuples : -```julia -function PlantSimEngine.inputs_(::HardDepSameScaleAvalModel) - (e2 = -Inf,) -end -``` - -The error returned will likely be a Julia error along the lines of : -```julia -[ERROR: MethodError: no method matching merge(::Float64, ::@NamedTuple{g::Float64}) - -Closest candidates are: -merge(::NamedTuple{()}, ::NamedTuple) -@ Base namedtuple.jl:337 -merge(::NamedTuple{an}, ::NamedTuple{bn}) where {an, bn} -@ Base namedtuple.jl:324 -merge(::NamedTuple, ::NamedTuple, NamedTuple...) -@ Base namedtuple.jl:343 - -Stacktrace: -[1] variables_multiscale(node::PlantSimEngine.HardDependencyNode{…}, organ::String, vars_mapping::Dict{…}, st::@NamedTuple{}) -... -``` -It is sometimes properly detected and explained on PlantSimEngine's side (when passing in tracked_outputs, for instance), but may also occur when declaring statuses. - -### Incorrectly declaring empty inputs or outputs - -The syntax for an empty NamedTuple is `NamedTuple()`. If instead one types `()` or `(,)`an error returned respectively by PlantSimEngine or Julia will be returned. - -## PlantSimEngine user errors - -Most of the following errors occur exclusively in multi-scale simulations, which has a slightly more complex API, but some are common to both single- and multi-scale simulations. - -### ModelMapping: providing a type name instead of a constructed instance - -```julia -m = ModelMapping(day=MyToyModel, week=MyToyModel2) -``` -This line is incorrect and will return -```julia -MethodError: no method matching inputs_(::Type{MyToyDayModel}) -``` - -The correct syntax is (assuming the corresponding constructor exists) : -```julia -m = ModelMapping(day=MyToyModel(), week=MyToyModel2()) -``` - -### Implementing a model: forgetting to import or prefix functions - -When implementing a model, you need to make sure that your implementation is correctly recognised as extending `PlantSimEngine` methods and types, and not writing new independent ones. - -In the following working toy model implementation, note that the `inputs_`, `outputs_` and [`run!`](@ref) function are all prefixed with the module name. If there were hard dependencies to manage, the [`dep`](@ref) function would also be identically prefixed. - -```julia -using PlantSimEngine -@process "toy" verbose = false - -struct ToyToyModel{T} <: AbstractToyModel - internal_constant::T -end - -function PlantSimEngine.inputs_(::ToyToyModel) - (a = -Inf, b = -Inf, c = -Inf) -end - -function PlantSimEngine.outputs_(::ToyToyModel) - (d = -Inf, e = -Inf) -end - - -function PlantSimEngine.run!(m::ToyToyModel, models, status, meteo, constants=nothing, extra_args=nothing) - status.d = m.internal_constant * status.a - status.e += m.internal_constant -end - -meteo = Weather([ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f=200.0), - Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f=200.0), - Atmosphere(T=18.0, Wind=1.0, Rh=0.65, Ri_PAR_f=100.0), -]) - -model = ModelMapping( - ToyToyModel(1), - status = ( a = 1, b = 0, c = 0), -) -to_initialize(model) -sim = PlantSimEngine.run!(model, meteo) -``` - -If you declare these functions without importing them first, or prefixing them with the module name, they will be considered to be part of your current environment, and won't be extending PlantSimEngine methods, which means PlantSimEngine will not be able to properly make use of your functions, and simulations are likely to error, or run incorrectly. - -Forgetting to prefix the [`run!`](@ref) function definition gives the following error : -```julia -ERROR: MethodError: no method matching run!(::ModelMapping{...}, ::TimeStepTable{Atmosphere{…}}) -The function [`run!`](@ref) exists, but no method is defined for this combination of argument types. - -Closest candidates are: - run!(::ToyToyModel, ::Any, ::Any, ::Any, ::Any, ::Any) - @ Main ~/path/to/file.jl:20 -``` - -Forgetting to prefix the `inputs_`or `outputs_` functions for your model might not always generate an error, depending on whether the variables declared in this function are present in your mapping's corresponding Status. - -In cases where they do throw an error, you may get the following kind of output: -```julia -ERROR: type NamedTuple has no field d -Stacktrace: - [1] setproperty!(mnt::Status{(:a, :b, :c), Tuple{…}}, s::Symbol, x::Int64) - @ PlantSimEngine ~/path/to/package/PlantSimEngine/src/component_models/Status.jl:100 - [2] run!(m::ToyToyModel{…}, models::@NamedTuple{…}, status::Status{…}, meteo::PlantMeteo.TimeStepRow{…}, constants::Constants{…}, extra_args::Nothing) - ... -``` - -!!! note - There may be more we can do on our end in the future to make the issue more obvious, but in the meantime it is safest to consistently prefix the methods you need to declare and call with `PlantSimEngine.`, or to explicitely import the functions you wish to extend, *e.g.*: `import PlantSimEngine: inputs_, outputs_`. - -### MultiScaleModel : forgetting a kwarg in the declaration - -A MultiScaleModel requires two kwargs, model and mapped_variables : - -```julia -models = MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[:TT_cu => :Scene,], - ) -``` - -Forgetting 'model=' : - -```julia -models = MultiScaleModel( - ToyLAIModel(), - mapped_variables=[:TT_cu => :Scene,], - ) -ERROR: MethodError: no method matching MultiScaleModel(::ToyLAIModel; mapped_variables::Vector{Pair{Symbol, String}}) -The type `MultiScaleModel` exists, but no method is defined for this combination of argument types when trying to construct it. - -Closest candidates are: - MultiScaleModel(::T, ::Any) where T<:AbstractModel got unsupported keyword argument "mapped_variables" - @ PlantSimEngine PlantSimEngine/src/mtg/MultiScaleModel.jl:188 - MultiScaleModel(; model, mapped_variables) - @ PlantSimEngine PlantSimEngine/src/mtg/MultiScaleModel.jl:191 -``` - -Forgetting 'mapped_variables=' : -```julia -models = MultiScaleModel( - model=ToyLAIModel(), - [:TT_cu => :Scene,], - ) - -ERROR: MethodError: no method matching MultiScaleModel(::Vector{Pair{Symbol, String}}; model::ToyLAIModel) -The type `MultiScaleModel` exists, but no method is defined for this combination of argument types when trying to construct it. - -Closest candidates are: - MultiScaleModel(; model, mapping) - @ PlantSimEngine PlantSimEngine/src/mtg/MultiScaleModel.jl:191 - MultiScaleModel(::T, ::Any) where T<:AbstractModel got unsupported keyword argument "model" -``` - -The message 'got unsupported keyword argument "model"' can be misleading, as in the error in this case is not that a kwarg is *unsupported*, but rather that a keyword argument is *missing*. - -### MultiScaleModel : variable not defined in Module - -A possible cause for this error is that a variable was declared instead of a symbol in a mapping for a multiscale model : - -```julia -mapping = ModelMapping(:Scale => -MultiScaleModel( - model = ToyModel(), - mapped_variables = [should_be_symbol => :Other_Scale] # should_be_symbol is a variable, likely not found in the current module -), -... -), -``` - -Here's the correct version : -```julia -mapping = ModelMapping(:Scale => -MultiScaleModel( - model = ToyModel(), - mapped_variables=[:should_be_symbol => :Other_Scale] # should_be_symbol is now a symbol -), -... -), -``` - -### Kwarg and arg parameter issues when calling run! - -There are, unfortunately, multiple ways of passing in arguments to the run! functions that will confuse dynamic dispatch. Some of it is due to imperfections in type declarations on PlantSimEngine's end and may be improved upon in the future. - -Here are a few examples when modifying the usual multiscale run! call in this working example: - -```julia -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) -mtg = Node(MultiScaleTreeGraph.NodeMTG("/", :Plant, 1, 1)) -var1 = 15.0 - -mapping = ModelMapping( - :Leaf => ( - Process1Model(1.0), - Process2Model(), - Process3Model(), - Status(var1=var1,) - ) -) - -outs = Dict( - :Leaf => (:var1,), # :non_existing_variable is not computed by any model -) - -run!(mtg, mapping, meteo_day, PlantMeteo.Constants(), tracked_outputs=outs) -``` - -The exact signature is this : -```julia -function run!( - object::MultiScaleTreeGraph.Node, - mapping::ModelMapping, - meteo=nothing, - constants=PlantMeteo.Constants(), - extra=nothing; - nsteps=nothing, - tracked_outputs=nothing, - check=true, - executor=ThreadedEx() -``` - -Arguments after the mtg and mapping all have a default value and are optional, and arguments after the ';' delimiter are kwargs and need to be named. - -If one forgets the mtg, a flaw in the way run! is defined will lead to this error : -```julia -run!(mapping, meteo_day, PlantMeteo.Constants(), tracked_outputs=outs) - -ERROR: MethodError: no method matching check_dimensions(::PlantSimEngine.TableAlike, ::Tuple{…}, ::DataFrame) -The function `check_dimensions` exists, but no method is defined for this combination of argument types. - -Closest candidates are: - check_dimensions(::Any, ::Any) - @ PlantSimEngine PlantSimEngine/src/checks/dimensions.jl:43 - ... -``` - -If one forgets the necessary 'tracked_outputs=' in the definition, outs will be interpreted as the 'extra' arg instead of a kwarg. 'extra' usually defaults to nothing, and is reserved in multiscale mode, leading to the following error : - -```julia -run!(mtg, mapping, meteo_day, PlantMeteo.Constants(), outs) - -ERROR: Extra parameters are not allowed for the simulation of an MTG (already used for statuses). -Stacktrace: - [1] error(s::String) - @ Base ./error.jl:35 - [2] run!(::PlantSimEngine.TreeAlike, object::PlantSimEngine.GraphSimulation{…}, meteo::DataFrames.DataFrameRows{…}, constants::Constants{…}, extra::Dict{…}; tracked_outputs::Nothing, check::Bool, executor::ThreadedEx{…}) -``` - -In case of a more generic error that returns a -For example, if one does the opposite and adds a non-existent kwarg, the generic dispatch failure has some more specific information : -`got unsupported keyword argument "constants"` - -```julia -run!(mtg, mapping, meteo_day, constants=PlantMeteo.Constants(), tracked_outputs=outs) - -ERROR: MethodError: no method matching run!(::Node{…}, ::Dict{…}, ::DataFrame, ::Dict{…}, ::Nothing; constants::Constants{…}) -This error has been manually thrown, explicitly, so the method may exist but be intentionally marked as unimplemented. - -Closest candidates are: - run!(::Node, ::Dict{String}, ::Any, ::Any, ::Any; nsteps, tracked_outputs, check, executor) got unsupported keyword argument "constants" -``` - -### Hard dependency process not present in the mapping - -Another weakness in the current error checking leads to an unclear Julia error if a model A is present in a mapping and has a hard dependency on a model B, but B is absent from the mapping. - -In the following example, A corresponds to Process3Model, which requires a model B implementing 'Process2Model' and referred to as 'process2'. -Looking at the source code for Process3Model, the hard dependency is declared here : -```julia -PlantSimEngine.dep(::Process3Model) = (process2=Process2Model,) -``` - -However, the model provided in the examples, Process2Model is absent from the mapping : - -```julia -simple_mtg = Node(MultiScaleTreeGraph.NodeMTG("/", :Plant, 1, 1)) -mapping = ModelMapping( - :Leaf => ( - Process3Model(), - Status(var5=15.0,) - ) -) -outs = Dict( - :Leaf => (:var5,), -) -run!(simple_mtg, mapping, meteo_day, tracked_outputs=outs) - -ERROR: type NamedTuple has no field process2 -Stacktrace: - [1] getproperty(x::@NamedTuple{process3::Process3Model}, f::Symbol) - @ Base ./Base.jl:49 - [2] run!(::Process3Model, models::@NamedTuple{…}, status::Status{…}, meteo::DataFrameRow{…}, constants::Constants{…}, extra::PlantSimEngine.GraphSimulation{…}) - ... -``` - -The fix is to add Process2Model() -or another model for the same process- to the mapping. - -### Status API ambiguity - -One current problem with PlantSimEngine's API is that declaring a simulation's Status or Statuses differs between single- and multi-scale. - -Returning to the example in [Implementing a model: forgetting to import or prefix functions](@ref), the single-scale mapping status was declared like this: - -```julia -model = ModelMapping( - ToyToyModel(1), - status = ( a = 1, b = 0, c = 0), -) -``` -If instead you replace `status = ...`with the multi-scale declaration: `Status(...)`, you will get the following error: - -```julia -ERROR: MethodError: no method matching process(::Status{(:a, :b, :c), Tuple{Base.RefValue{Int64}, Base.RefValue{Int64}, Base.RefValue{Int64}}}) -The function `process` exists, but no method is defined for this combination of argument types. - -Closest candidates are: - process(::Pair{Symbol, A}) where A<:AbstractModel - @ PlantSimEngine ~/path/to/pkg/PlantSimEngine/src/Abstract_model_structs.jl:16 - process(::A) where A<:AbstractModel - @ PlantSimEngine ~/path/to/pkg/PlantSimEngine/src/Abstract_model_structs.jl:13 - -Stacktrace: - [1] (::PlantSimEngine.var"#5#6")(i::Status{(:a, :b, :c), Tuple{Base.RefValue{…}, Base.RefValue{…}, Base.RefValue{…}}}) - @ PlantSimEngine ./none:0 - [2] iterate -``` - -If you do the opposite in a multi-scale simulation by replacing the necessary `Status(...)` with `status = ...`, you may get an `ERROR: syntax: invalid named tuple element` error. Here's some output when tinkering with the Toy Plant tutorial's mapping: - -```julia -ERROR: syntax: invalid named tuple element "MultiScaleModel(...)" around /path/to/Pkg/PlantSimEngine/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation3.jl:196 -Stacktrace: - [1] top-level scope - @ ~/path/to/pkg/PlantSimEngine/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation3.jl:196 -``` -or -```julia -ERROR: syntax: invalid named tuple element "ToyRootGrowthModel(50, 10)" around /path/to/Pkg/PlantSimEngine/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation3.jl:196 -Stacktrace: - [1] top-level scope - @ ~/path/to/Pkg/PlantSimEngine/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation3.jl:196 -``` - -## Forgetting to declare a scale in the mapping but having variables point to it - -If there is a need to collect variables at two different scales, and one scale is completely absent from the mapping, the error currently occurs on the Julia side : - -```julia -# No models at the E3 scale in the mapping ! - -:E2 => ( - MultiScaleModel( - model = HardDepSameScaleEchelle2Model(), - mapped_variables=[:c => :E1 => :c, :e3 => :E3 => :e3, :f3 => :E3 => :f3,], - ), - ), - -Exception has occurred: KeyError -* -KeyError: key :E3 not found -Stacktrace: -[1] hard_dependencies(mapping::Dict{String, Tuple{Any, Any}}; verbose::Bool) -@ PlantSimEngine ......./src/dependencies/hard_dependencies.jl:175 -... -``` - -### Parenthesis placement when declaring a mapping - -An unintuitive error encountered in the past when defining a mapping : - -```julia -ERROR: ArgumentError: AbstractDict(kv): kv needs to be an iterator of 2-tuples or pairs -``` - -may occur when forgetting the parenthesis after '=>' in a mapping declaration, and combining it with another parenthesis error. - -```julia -mapping = ModelMapping( "Scale" => (ToyAssimGrowthModel(0.0, 0.0, 0.0), ToyCAllocationModel(), Status( TT_cu=Vector(cumsum(meteo_day.TT))), ), ) -``` - -Other errors such as: - -```julia -ERROR: MethodError: no method matching Dict(::Pair{String, ToyAssimGrowthModel{Float64}}, ::ToyCAllocationModel, ::Status{(:TT_cu,), Tuple{Base.RefValue{…}}}) -The type `Dict` exists, but no method is defined for this combination of argument types when trying to construct it. - -Closest candidates are: - Dict(::Pair{K, V}...) where {K, V} -``` - -often indicate a likely syntax error somewhere in the mapping definition. - -### Empty status vectors in multi-scale simulations - -This situation won't trigger an error. Unexpectedly empty vectors can be returned as outputs if you happen to forget to a node at the corresponding scale in the MTG, and no organ creation occurs for that node. - -Here's an example taken from the [Converting a single-scale simulation to multi-scale](@ref) page. It was modified by removing the :Plant node in the dummy MTG passed into the [`run!`](@ref)function. Without that :Plant node, only :Scene-scale models can run initially, and since no nodes are created, :Plant-scale models will never be run. - -```julia -PlantSimEngine.@process "tt_cu" verbose = false - -struct ToyTt_CuModel <: AbstractTt_CuModel end - -function PlantSimEngine.run!(::ToyTt_CuModel, models, status, meteo, constants, extra=nothing) - status.TT_cu += - meteo.TT -end - -function PlantSimEngine.inputs_(::ToyTt_CuModel) - NamedTuple() # No input variables -end - -function PlantSimEngine.outputs_(::ToyTt_CuModel) - (TT_cu=-Inf,) -end - -mapping_multiscale = ModelMapping( - :Scene => ToyTt_CuModel(), - :Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => :Scene, - ], - ), - Beer(0.5), - ToyRUEGrowthModel(0.2), - ), -) - -mtg_multiscale = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Plant, 0, 0),) -#plant = MultiScaleTreeGraph.Node(mtg_multiscale, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - -out_multiscale = run!(mtg_multiscale, mapping_multiscale, meteo_day) - -out_multiscale[:Plant][:LAI] -``` - -In the above code, uncommenting the second line will add a :Plant node to the MTG, and the simulation will then behave as intuitively expected. diff --git a/docs/src/troubleshooting_and_testing/tips_and_workarounds.md b/docs/src/troubleshooting_and_testing/tips_and_workarounds.md deleted file mode 100644 index 27f19a067..000000000 --- a/docs/src/troubleshooting_and_testing/tips_and_workarounds.md +++ /dev/null @@ -1,112 +0,0 @@ -# Tips and workarounds - -## PlantSimEngine is actively being developed - -PlantSimEngine, despite the somewhat abstract codebase and generic simulation ambitions, is quite grounded in reality. There IS a desire to accomodate for a wide range of possible simulations, without constraining the user too much, but most features are developed on an as-needed basis, and grow out of necessity, partly from the requirements of an increasingly complex and refined implementation of an oil palm model, [XPalm](https://github.com/PalmStudio/XPalm.jl). - -Since the oil palm model is actively being developed, and some features aren't ready in PlantSimEngine, or require a lot of rewriting that we're not certain would be worth it (especially if it ends up constraining the codebase or what the user can do), some workarounds and shortcuts are occasionally used to circumvent a limitation. - -There are also a couple of features that are quick hacks or that are meant for quick and dirty prototyping, not for production. - -We'll list a few of them here, and will likely add some entry in the future listing some built-in limitations or implicit expectations of the package. - -```@contents -Pages = ["tips_and_workarounds.md"] -Depth = 2 -``` - -## Making use of past states in multi-scale simulations - -It is possible to make use of the value of a variable in the past simulation timestep via the [`PreviousTimeStep`](@ref) mechanism in the mapping API (In fact, as mentioned elsewhere, it is the default way to break undesirable cyclic dependencies that can come up when coupling models, see : [Avoiding cyclic dependencies](@ref)). - -However, it is not possible to go beyond that through the mapping API. Something like `PreviousTimeStep(PreviousTimeStep(PreviousTimeStep(:carbon_biomass)))` is not supported. Don't do that. - -One way to access prior variable states is simply to write an ad hoc model that stores a few values into an array or however many variables you might need, which you can then update every timestep and feed into other models that might need it. - -## Having a variable simultaneously as input and output of a model - -One current limitation of `PlantSimEngine` that can be occasionally awkward is that using the same variable name as input and output in a single model is unsupported. - -(On a related note : it is not possible to have two variables with the same name *in the same scale*. They are considered as the same variable.) - -The reason being that it is usually impossible to automatically determine how the coupling is supposed to work out, when other dependencies latch onto such a model. The user would have to explicitely declare some order of simulation between several models, and some amount of programmer work would also be necessary to implement that extra API feature into `PlantSimEngine`. - -We haven't found an approach that was fully satisfactory from both a code simplicity and an API convenience POV. Especially when prototyping and adding in new models, as that might require redeclaring the simulation order for those specific variables. - -There are two workarounds : - -- One possibly awkward approach is to rename one of the variables. It is not ideal, of course, as it means you might not be able to use a predefined model 'out of the box', but it does not have any of the tradeoffs and constraints mentioned above. - -- In many other situations one can work with what PlantSimEngine already provides. - -For example, one model in [XPalm.jl](https://github.com/PalmStudio/XPalm.jl/blob/main/src/plant/phytomer/leaves/leaf_pruning.jl) handles leaf pruning, affecting biomass. A straightforward implementation would be to have a `leaf_biomass` variable as both input and output. The workaround is to instead output a variable `leaf_biomass_pruning_loss` and to have that as input in the next timestep to compute the new leaf biomass. - -[Part 3](../multiscale/multiscale_example_3.md) of the Toy Plant tutorial does something similar for its carbon stock. The `carbon_stock` variable indicates how much carbon is available for root and internode growth, but instead of updating it and passing it along after the root growth decision model decided whether or not roots should be added, that model computes a `carbon_stock_updated_after_roots` which is then used by the internode growth model. - -This change in design avoids model order ambiguity and also improves readability, and makes sense in terms of PlantSimEngine's philosophy. - -## [Multiscale : passing in a vector in a mapping status at a specific scale](@id multiscale_vector) - -!!! note - This section is a little more advanced and not recommended for beginners - -You may have noticed that sometimes a vector (1-dimensional array) variable is passed into the [`status`](@ref) component of a [`ModelMapping`](@ref) in documentation examples (An example here with cumulative thermal time : [Model switching](@ref)). - -This is practical for simple simulations, or when quickly prototyping, to avoid having to write a model specifically for it. Whatever models make use of that variable are provided with one element corresponding to the current timestep every iteration. - -In multi-scale simulations, this feature is also supported, though not part of the main API. The way outputs and statuses work is a little different, so that little convenience feature is not as straightforward. - -It remains a convenience path for prototyping, and it is still not tested for -more complex interactions, so it may interact badly with variables that are -mapped to different scales or in unusual dependency couplings. - -The way to use this is as follows: - -Call the function `replace_mapping_status_vectors_with_generated_models(mapping_with_vectors_in_status, timestep_model_organ_level, nsteps)`on your mapping. - -It will parse your mapping, generate custom models to store and feed the vector values each timestep, and return the new mapping you can then use for your simulation. It also slips in a couple of internal models that provide the timestep index to these models (so note that symbols `:current_timestep` and `:next_timestep` will be declared for that mapping). You can decide which scale/organ level you want those models to be in via the `timestep_model_organ_level`parameter. `nsteps` is used as a sanity check, and expects you to provide the amount of simulation timesteps. - -!!! warning - Only subtypes of AbstractVector present in statuses will be affected. In some cases, meteo values might need a small conversion. For instance : - ``` - meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - status(TT_cu=cumsum(meteo_day.TT),)``` - - cumsum(meteo_day.TT) actually returns a CSV.SentinelArray.ChainedVectors{T, Vector{T}}, which is not a subtype of AbstractVector. - Replacing it with Vector(cumsum(meteo_day.TT)) will provide an adequate type. - -Here's an example usage, fixing the first attempt at [Converting a single-scale simulation to multi-scale](@ref): - -```julia -using PlantSimEngine -using PlantSimEngine.Examples -using PlantMeteo, Dates -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -# Direct translation of the single-scale simulation -mapping_pseudo_multiscale = ModelMapping( -:Plant => ( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2), - Status(TT_cu=cumsum(meteo_day.TT),) - ), -) - -mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Plant, 1, 0),) - -# will generate an error as vectors can't be directly passed into a Status in multi-scale simulations -#out_pseudo_multiscale_error = run!(mtg, mapping_pseudo_multiscale, meteo_day) - -mapping_pseudo_multiscale_adjusted = PlantSimEngine.replace_mapping_status_vectors_with_generated_models(mapping_pseudo_multiscale, :Plant, PlantSimEngine.get_nsteps(meteo_day)) - -out_pseudo_multiscale_successful = run!(mtg, mapping_pseudo_multiscale_adjusted, meteo_day) - -``` - - -This feature is likely to break in simulations that make use of planned future features (such as mixing models with different timesteps), without guarantee of a fix on a short notice. Again, bear in mind it is mostly a convenient shortcut for prototyping, when doing multi-scale simulations. - -## Cyclic dependencies in single-scale simulations - -Cyclic dependencies can happen in single-scale simulations, but the PreviousTimestep feature currently isn't available. Hard dependencies are one way to deal with them, creating a multi-scale simulation with a single effective scale is also an option. diff --git a/docs/src/tutorials/growing_plant/part1_growth.md b/docs/src/tutorials/growing_plant/part1_growth.md new file mode 100644 index 000000000..4a5646e19 --- /dev/null +++ b/docs/src/tutorials/growing_plant/part1_growth.md @@ -0,0 +1,32 @@ +# 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 become visible after the current timestep completes. The +new leaf receives status and application targets during refresh and starts on +the following timestep; it cannot consume the resource that created it in the +same kernel call. + +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(extra)`, 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. `explain_applications` should show that the new leaf is absent +during its creation step and present after the between-step refresh. diff --git a/docs/src/tutorials/growing_plant/part2_roots_water.md b/docs/src/tutorials/growing_plant/part2_roots_water.md new file mode 100644 index 000000000..12ab9ee35 --- /dev/null +++ b/docs/src/tutorials/growing_plant/part2_roots_water.md @@ -0,0 +1,34 @@ +# 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. 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 +Inputs( + :root_uptake => Many( + scale=:Root, within=Subtree(), application=:root_absorption, + var=:uptake, policy=Integrate(), window=Day(1), + ), + :soil_water => One( + scale=:Soil, within=SceneScope(), application=:soil_water, + var=:water, + ), +) +``` + +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 `input_value` or `explain_bindings`. diff --git a/docs/src/tutorials/growing_plant/part3_debugging.md b/docs/src/tutorials/growing_plant/part3_debugging.md new file mode 100644 index 000000000..f513c2a31 --- /dev/null +++ b/docs/src/tutorials/growing_plant/part3_debugging.md @@ -0,0 +1,32 @@ +# 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. `explain_initialization(model)` for missing state or environment values. +2. `explain_bindings(model)` for source scope and multiplicity. +3. `explain_writers(model)` for competing canonical outputs. +4. `explain_calls(model)` for call-only targets and target cardinality. +5. `explain_schedule(model)` for cadence and root ordering. +6. `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 during the kernel that created it. Before the next timestep, compilation +refreshes targets, carriers, calls, writer validation, schedules, and requested +outputs. Geometry-only movement refreshes only affected spatial bindings where +possible. diff --git a/docs/src/working_with_data/fitting.md b/docs/src/working_with_data/fitting.md index 0232acb21..9db987efa 100644 --- a/docs/src/working_with_data/fitting.md +++ b/docs/src/working_with_data/fitting.md @@ -1,76 +1,56 @@ -# Parameter fitting +# Parameter Fitting -```@setup usepkg -using PlantSimEngine, PlantMeteo, Dates, Statistics, DataFrames -using PlantSimEngine.Examples - -meteo = Atmosphere(T=20.0, Wind=1.0, P=101.3, Rh=0.65, Ri_PAR_f=300.0) -m = ModelMapping(Beer(0.6), status=(LAI=2.0,)) -run!(m, meteo) - -df = DataFrame(aPPFD=m[:aPPFD][1], LAI=m.status.LAI[1], Ri_PAR_f=meteo.Ri_PAR_f[1]) -``` - -## The fit method - -Models are often calibrated using data, but the calibration process is not always the same depending on the model, and the data available to the user. - -`PlantSimEngine` defines a generic [`fit`](@ref) function that allows modelers provide a fitting algorithm for their model, and for users to use this method to calibrate the model using data. - -The function does nothing in this package, it is only defined to provide a common interface for all the models. It is up to the modeler to implement the method for their model. - -The method is implemented as a function with the following design pattern: the call to the function should take the model type as the first argument (T::Type{<:AbstractModel}), the data as the second argument (as a `Table.jl` compatible type, such as `DataFrame`), and any more information as keyword arguments, *e.g.* constants or parameters initializations with default values when necessary. - -## Example with Beer - -The example script (see `src/examples/Beer.jl`) that implements the `Beer` model provides an example of how to implement the `fit` method for a model: +`PlantSimEngine.fit` is a 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. ```julia -function PlantSimEngine.fit(::Type{Beer}, df; J_to_umol=PlantMeteo.Constants().J_to_umol) - k = Statistics.mean(log.(df.Ri_PAR_f ./ (df.PPFD ./ J_to_umol)) ./ df.LAI) +function PlantSimEngine.fit( + ::Type{Beer}, + data; + J_to_umol=PlantMeteo.Constants().J_to_umol, +) + k = Statistics.mean( + log.(data.Ri_PAR_f ./ (data.aPPFD ./ J_to_umol)) ./ data.LAI, + ) return (k=k,) end ``` -The function takes a `Beer` type as the first argument, the data as a `Tables.jl` -compatible type, such as a `DataFrame` as the second argument, and the `J_to_umol` constant as a keyword argument, which is used to convert between μ mol m⁻² s⁻¹ and J m⁻² s⁻¹. - -`df` should contain the columns `PPFD` (μ mol m⁻² s⁻¹), `LAI` (m² m⁻²) and `Ri_PAR_f` (W m⁻²). The function then computes `k` based on these values, and returns it as a `NamedTuple` of the form `(parameter_name=parameter_value,)`. - -Here's an example of how to use the `fit` method: +The result should be a `NamedTuple` of fitted parameters. -Importing the script first: - -```julia -using PlantSimEngine, PlantMeteo, Dates, DataFrames, Statistics -# Import the examples defined in the `Examples` sub-module: +```@example fitting +using PlantSimEngine, PlantMeteo, Dates, DataFrames using PlantSimEngine.Examples -``` - -Defining the meteo data: - -```@example usepkg -meteo = Atmosphere(T=20.0, Wind=1.0, P=101.3, Rh=0.65, Ri_PAR_f=300.0) -``` - -Computing the `PPFD` values from the `Ri_PAR_f` values using the `Beer` model (with `k=0.6`): - -```@example usepkg -m = ModelMapping(Beer(0.6), status=(LAI=2.0,)) -run!(m, meteo) -``` - -Now we can define the "data" to fit the model using the simulated `PPFD` values: - -```@example usepkg -df = DataFrame(aPPFD=m[:aPPFD][1], LAI=m.status.LAI[1], Ri_PAR_f=meteo.Ri_PAR_f[1]) -``` - -And finally we can fit the model using the `fit` method: -```@example usepkg -fit(Beer, df) +meteo = Atmosphere( + T=20.0, + Wind=1.0, + P=101.3, + Rh=0.65, + Ri_PAR_f=300.0, + duration=Hour(1), +) + +model = CompositeModel( + Beer(0.6); + status=(LAI=2.0,), + id=:leaf, + scale=:Leaf, + environment=meteo, +) + +run!(model) +leaf = only(model_objects(model; scale=:Leaf)) +data = DataFrame( + aPPFD=[leaf.status.aPPFD], + LAI=[leaf.status.LAI], + Ri_PAR_f=[meteo.Ri_PAR_f[1]], +) + +fit(Beer, data) ``` -!!! note - This is a dummy example to show that the fitting method works. A real application would fit the parameter values on the data directly. \ No newline at end of file +This example recovers the parameter used to generate the synthetic +observation. Real calibration methods can use any optimizer or uncertainty +framework and may return additional diagnostics. diff --git a/docs/src/working_with_data/floating_point_accumulation_error.md b/docs/src/working_with_data/floating_point_accumulation_error.md deleted file mode 100644 index 6153fdc62..000000000 --- a/docs/src/working_with_data/floating_point_accumulation_error.md +++ /dev/null @@ -1,161 +0,0 @@ -# Floating-point considerations - -```@setup usepkg -using PlantSimEngine -using PlantSimEngine.Examples -using PlantMeteo, Dates, MultiScaleTreeGraph -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -models = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2); - status=(TT_cu=cumsum(meteo_day.TT),), -) - -out_singlescale = run!(models, meteo_day) -``` -## Investigating a discrepancy - -In the [Converting a single-scale simulation to multi-scale](@ref) page, a single-scale simulation was converted to an equivalent multiscale simulation, and outputs were compared. One detail that was glossed over, but important to bear in mind as a PlantSimEngine user is related to floating-point approximations. - -### Single-scale simulation - -```@example usepkg -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -models_singlescale = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2); - status=(TT_cu=cumsum(meteo_day.TT),), -) - -outputs_singlescale = run!(models_singlescale, meteo_day) -outputs_singlescale[1:3,:] # show the first 3 rows of the output -``` - -### Multi-scale equivalent - -```@example usepkg -PlantSimEngine.@process "tt_cu" verbose = false - -struct ToyTt_CuModel <: AbstractTt_CuModel end - -function PlantSimEngine.run!(::ToyTt_CuModel, models, status, meteo, constants, extra=nothing) - status.TT_cu += - meteo.TT -end - -function PlantSimEngine.inputs_(::ToyTt_CuModel) - NamedTuple() # No input variables -end - -function PlantSimEngine.outputs_(::ToyTt_CuModel) - (TT_cu=0.0,) -end - -mapping_multiscale = ModelMapping( - :Scene => ToyTt_CuModel(), - :Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => :Scene, - ], - ), - Beer(0.5), - ToyRUEGrowthModel(0.2), - ), -) - -mtg_multiscale = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Plant, 0, 0),) - plant = MultiScaleTreeGraph.Node(mtg_multiscale, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - -outputs_multiscale = run!(mtg_multiscale, mapping_multiscale, meteo_day) -``` - -### Output comparison - -```@setup usepkg -mapping_multiscale = ModelMapping( - :Scene => ToyTt_CuModel(), - :Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => :Scene, - ], - ), - Beer(0.5), - ToyRUEGrowthModel(0.2), - ), -) - -mtg_multiscale = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 0, 0),) - plant = MultiScaleTreeGraph.Node(mtg_multiscale, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - -outputs_multiscale = run!(mtg_multiscale, mapping_multiscale, meteo_day) -``` - -```@example usepkg - -computed_TT_cu_multiscale = [outputs_multiscale[:Scene][i].TT_cu for i in 1:length(outputs_multiscale[:Scene])] -is_approx_equal = length(unique(computed_TT_cu_multiscale .≈ outputs_singlescale.TT_cu)) == 1 -``` - -Why was the comparison only approximate ? Why `≈` instead of `==`? - -Let's try it out. What if write instead: - -```@example usepkg -computed_TT_cu_multiscale = [outputs_multiscale[:Scene][i].TT_cu for i in 1:length(outputs_multiscale[:Scene])] -is_perfectly_equal = length(unique(computed_TT_cu_multiscale .== outputs_singlescale.TT_cu)) == 1 -``` - -Why is this false? Let's look at the data. - -Looking more closely at the output, we can notice that values are identical up to timestep #105 : - -```@example usepkg -(computed_TT_cu_multiscale .== outputs_singlescale.TT_cu)[104] -``` - -```@example usepkg -(computed_TT_cu_multiscale .== outputs_singlescale.TT_cu)[105] -``` - -We have the values 132.33333333333331 (multi-scale) and 132.33333333333334 (single-scale). The final output values are : 2193.8166666666643 (multi-scale) and 2193.816666666666 (single-scale). - -The divergence isn't huge, but in other situations or over more timesteps it could start becoming a problem. - -## Floating-point summation - -The reason values aren't identical, is due to the fact that many numbers do not have an exact floating point representation. A classical example is the fact that [0.1 + 0.2 != 0.3](https://blog.reverberate.org/2016/02/06/floating-point-demystified-part2.html) : - -```@example usepkg -println(0.1 + 0.2 - 0.3) -``` - -When summing many numbers, depnding on the order in which they are summed, floating-point approximation errors may aggregate more or less quickly. - -The default summation per-timestep in our example `Toy_Tt_CuModel` was a naive summation. The `cumsum` function used in the single-scale simulation to directly compute the TT_cu uses a pairwise summation method that provides approximation error on fewer digits compared to naive summation. Errors aggregate more slowly. - -In our simple example, using Float64 values, the difference wasn't significant enough to matter, but if you are writing a simulation over many timesteps or aggregating a value over many nodes, you may need to alter models to avoid numerical errors blowing up due to floating-point accuracy. - -Depending on what value is being computed and the mathematical operations used, changes may range from applying a simple scale to a range of values, to significant refactoring. - - -## Other links related to floating-point numerical concerns - -Note that many of the examples in these blogposts discuss Float32 accuracy. Float64 values have several extra precision bits to work. - -A series of blog posts on floating-point accuracy: [https://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/](https://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/) -Floating-Point Visually Explained : [https://fabiensanglard.net/floating_point_visually_explained/](https://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/) -Examples of floating point problems: [https://jvns.ca/blog/2023/01/13/examples-of-floating-point-problems/](https://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/) - -Relating specifically to floating-point sums: - -Pairwise summation: [https://en.wikipedia.org/wiki/Pairwise_summation](https://en.wikipedia.org/wiki/Pairwise_summation) -Kahan summation: [https://en.wikipedia.org/wiki/Kahan_summation_algorithm](https://en.wikipedia.org/wiki/Kahan_summation_algorithm) -Taming Floating-Point Sums: [https://orlp.net/blog/taming-float-sums/](https://orlp.net/blog/taming-float-sums/) diff --git a/docs/src/working_with_data/inputs.md b/docs/src/working_with_data/inputs.md deleted file mode 100644 index 3c94c985b..000000000 --- a/docs/src/working_with_data/inputs.md +++ /dev/null @@ -1,94 +0,0 @@ -# Input types - -[`run!`](@ref) usually takes two inputs: a [`ModelMapping`](@ref) and data for the meteorology. The data for the meteorology is usually provided for one time step using an `Atmosphere`, or for several time-steps using a `TimeStepTable{Atmosphere}`. The [`ModelMapping`](@ref) can also be provided as a singleton, or as a vector or dictionary of. - -[`run!`](@ref) knows how to handle these data formats via the [`PlantSimEngine.DataFormat`](@ref) trait (see [this blog post](https://www.juliabloggers.com/the-emergent-features-of-julialang-part-ii-traits/) to learn more about traits). For example, we tell PlantSimEngine that a `TimeStepTable` should be handled like a table by implementing the following trait: - -```julia -DataFormat(::Type{<:PlantMeteo.TimeStepTable}) = TableAlike() -``` - -If you need to use a different data format for the meteorology, you can implement a new trait for it. For example, if you have a table-alike data format, you can implement the trait like this: - -```julia -DataFormat(::Type{<:MyTableFormat}) = TableAlike() -``` - -There are two other traits available: `SingletonAlike` for a data format representing one time-step only, and `TreeAlike` for trees, which is used for MultiScaleTreeGraphs nodes (not generic at this time). - -## Promoting status variable types - -Use the `type_promotion` keyword on [`ModelMapping`](@ref) when the default input and output values declared by models should be converted to another type: - -```julia -models = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2); - status=(TT_cu=cumsum(meteo_day.TT),), - type_promotion=Dict(Real => Float32), -) -``` - -For single-scale mappings, `type_promotion` is applied while the backing status is constructed. It follows the same semantics as the deprecated [`ModelList`](@ref): model-provided default values are converted, while values explicitly passed in `status` keep the type chosen by the user. If those values should also be `Float32`, pass them as `Float32` values directly. - -For multiscale mappings, the per-node statuses do not exist when [`ModelMapping`](@ref) is constructed. The promotion map is stored on the mapping and applied when the MTG simulation is initialized: - -```julia -mapping = ModelMapping( - :Scene => ToyTt_CuModel(), - :Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => :Scene, - ], - ), - Beer(0.5), - ToyRUEGrowthModel(0.2), - ); - type_promotion=Dict(Float64 => Float32, Vector{Float64} => Vector{Float32}), -) - -outputs = run!(mtg, mapping, meteo_day) -``` - -The same promotion can also be passed at MTG run time: - -```julia -outputs = run!( - mtg, - mapping, - meteo_day; - type_promotion=Dict(Float64 => Float32, Vector{Float64} => Vector{Float32}), -) -``` - -In multiscale runs, type promotion is used by `GraphSimulation` during status template creation, `RefVector` creation, output preallocation, and initialization from MTG node attributes. - - -## Special considerations for new input types - -If you want to use a custom data format for the inputs, you need to make sure some methods are implemented for your data format depending on your use-cases. - -For example if you use models that need to get data from a different time step (*e.g.* a model that needs to get the previous day's temperature), you need to make sure that the data from the other time-steps can be accessed from the current time-step. - -To do so, you need to implement the following methods for your structure that defines your rows: - -- `Base.parent`: return the parent table of the row, *e.g.* the full DataFrame -- `PlantMeteo.rownumber`: return the row number of the row in the parent table, *e.g.* the row number in the DataFrame -- (Optionnally) `PlantMeteo.row_from_parent(row, i)`: return row `i` from the parent table, *e.g.* the row `i` from the DataFrame. This is only needed if you want high performance, the default implementation calls `Tables.rows(parent(row))[i]`. - -!!! compat - `PlantMeteo.rownumber` is temporary. It soon will be replaced by `DataAPI.rownumber` instead, which will be also used by *e.g.* DataFrames.jl. See [this Pull Request](https://github.com/JuliaData/DataAPI.jl/issues/60). - -## Working with weather data - -Here's a quick example showcasing how to export the example weather data to your own file : - -```julia -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) -PlantMeteo.write_weather("examples/meteo_day.csv", meteo_day, duration = Dates.Day) -``` - -If you wish to filter weather data, reshape it, adjust it, write it, you'll find some more examples in PlantMeteo's [API reference](https://palmstudio.github.io/PlantMeteo.jl/stable/API/). diff --git a/docs/src/working_with_data/reducing_dof.md b/docs/src/working_with_data/reducing_dof.md deleted file mode 100644 index 1a4b925de..000000000 --- a/docs/src/working_with_data/reducing_dof.md +++ /dev/null @@ -1,121 +0,0 @@ -# Reducing the DoF - -```@setup usepkg -using PlantSimEngine, PlantMeteo, Dates -# Import the examples defined in the `Examples` sub-module: -using PlantSimEngine.Examples - -meteo = Atmosphere(T = 20.0, Wind = 1.0, P = 101.3, Rh = 0.65) -struct ForceProcess1Model <: AbstractProcess1Model end -PlantSimEngine.inputs_(::ForceProcess1Model) = (var3=-Inf,) -PlantSimEngine.outputs_(::ForceProcess1Model) = (var3=-Inf,) -function PlantSimEngine.run!(::ForceProcess1Model, models, status, meteo, constants=nothing, extra=nothing) - return nothing -end -``` - -## Introduction - -### Why reduce the degrees of freedom - -Reducing the degrees of freedom in a model, by forcing certain variables to measurements, can be useful for several reasons: - -1. It can prevent overfitting by constraining the model and making it less complex. -2. It can help to better calibrate the other components of the model by reducing the co-variability of the variables (see [Parameter degeneracy](@ref)). -3. It can lead to more interpretable models by identifying the most important variables and relationships. -4. It can improve the computational efficiency of the model by reducing the number of variables that need to be estimated. -5. It can also help to ensure that the model is consistent with known physical or observational constraints and improve the credibility of the model and its predictions. -6. It is important to note that over-constraining a model can also lead to poor fits and false conclusions, so it is essential to carefully consider which variables to constrain and to what measurements. - -## Parameter degeneracy - -The concept of "degeneracy" or "parameter degeneracy" in a model occurs when two or more variables in a model are highly correlated, and small changes in one variable can be compensated by small changes in another variable, so that the overall predictions of the model remain unchanged. Degeneracy can make it difficult to estimate the true values of the variables and to determine the unique solutions of the model. It also makes the model sensitive to the initial conditions (*e.g.* the parameters) and the optimization algorithm used. - -Degeneracy is related to the concept of "co-variability" or "collinearity", which refers to the degree of linear relationship between two or more variables. In a degenerate model, two or more variables are highly co-variate, meaning that they are highly correlated and can produce similar predictions. By fixing one variable to a measured value, the model will have less flexibility to adjust the other variables, which can help to reduce the co-variability and improve the robustness of the model. - -This is an important topic in plant/crop modelling, as the models are very often degenerate. It is most often referred to as "multicollinearity" in the field. In the context of model calibration, it is also known as "parameter degeneracy" or "parameter collinearity". In the context of model reduction, it is also known as "redundancy" or "redundant variables". - -## Reducing the DoF in PlantSimEngine - -### Soft-coupled models - -PlantSimEngine provides a simple way to reduce the degrees of freedom in a model by constraining the values of some variables to measurements. - -Let's define a model list as usual with the seven processes from `examples/dummy.jl`: - -```@example usepkg -using PlantSimEngine, PlantMeteo, Dates -# Import the examples defined in the `Examples` sub-module: -using PlantSimEngine.Examples - -meteo = Atmosphere(T = 20.0, Wind = 1.0, P = 101.3, Rh = 0.65) -m = ModelMapping( - Process1Model(2.0), - Process2Model(), - Process3Model(), - Process4Model(), - Process5Model(), - Process6Model(), - Process7Model(), - status=(var0 = 0.5,) -) - -run!(m, meteo) - -status(m) -``` - -Let's say that `m` is our complete model, and that we want to reduce the degrees of freedom by constraining the value of `var9` to a measurement, which was previously computed by `Process7Model`, a soft-dependency model. It is very easy to do this in PlantSimEngine: just remove the model from the model list and give the value of the measurement in the status: - -```@example usepkg -m2 = ModelMapping( - Process1Model(2.0), - Process2Model(), - Process3Model(), - Process4Model(), - Process5Model(), - Process6Model(), - status=(var0 = 0.5, var9 = 10.0), -) - -out = run!(m2, meteo) -``` - -And that's it ! The models that depend on `var9` will now use the measured value of `var9` instead of the one computed by `Process7Model`. - -### Hard-coupled models - -It is a bit more complicated to reduce the degrees of freedom in a model that is hard-coupled to another model, because it calls the [`run!`](@ref) method of the other model. - -In this case, we need to replace the old model with a new model that forces the value of the variable to the measurement. This is done by giving the measurements as inputs of the new model, and returning nothing so the value is unchanged. - -Starting from the model list with the seven processes from above, but this time let's say that we want to reduce the degrees of freedom by constraining the value of `var3` to a measurement, which was previously computed by `Process1Model`, a hard-dependency model. It is very easy to do this in PlantSimEngine: just replace the model by a new model that forces the value of `var3` to the measurement: - -```@example usepkg -struct ForceProcess1Model <: AbstractProcess1Model end -PlantSimEngine.inputs_(::ForceProcess1Model) = (var3=-Inf,) -PlantSimEngine.outputs_(::ForceProcess1Model) = (var3=-Inf,) -function PlantSimEngine.run!(::ForceProcess1Model, models, status, meteo, constants=nothing, extra=nothing) - return nothing -end -``` - -Now we can create a new model list with the new model for `process7`: - -```@example usepkg -m3 = ModelMapping( - ForceProcess1Model(), - Process2Model(), - Process3Model(), - Process4Model(), - Process5Model(), - Process6Model(), - Process7Model(), - status = (var0=0.5,var3 = 10.0) -) - -out = run!(m3, meteo) -``` - -!!! note - We could also eventually provide the measured variable using the meteo data, but it is not recommended. The meteo data is meant to be used for the meteo variables only, and not for the model variables. It is better to use the status for that. diff --git a/docs/src/working_with_data/visualising_outputs.md b/docs/src/working_with_data/visualising_outputs.md deleted file mode 100644 index e7d833e2a..000000000 --- a/docs/src/working_with_data/visualising_outputs.md +++ /dev/null @@ -1,98 +0,0 @@ -```@setup usepkg -# ] add PlantSimEngine, PlantMeteo -using PlantSimEngine, PlantMeteo, Dates - -# Include the model definition from the examples folder: -using PlantSimEngine.Examples - -# Import the example meteorological data: -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -# Define the list of models for coupling: -model = ModelMapping( - ToyLAIModel(), - Beer(0.6), - status=(TT_cu=cumsum(meteo_day[:, :TT]),), # Pass the cumulated degree-days as input to `ToyLAIModel`, this could also be done using another model -) - -# Run the simulation: -sim_out = run!(model, meteo_day) - -``` - -# Visualizing outputs and data - -## Output structure - -PlantSimEngine's run! functions return for each timestep the state of the variables that were requested using the `tracked_outputs` kwarg (or the state of every variable if this kwarg was left unspecified). Multi-scale simulations also indicate which organ and MTG node these state variables are related to. - -Here's an example indicating how to plot output data using CairoMakie, a package used for plotting. - -```@example usepkg -# ] add PlantSimEngine, PlantMeteo -using PlantSimEngine, PlantMeteo, Dates - -# Include the model definition from the examples folder: -using PlantSimEngine.Examples - -# Import the example meteorological data: -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -# Define the list of models for coupling: -models = ModelMapping( - ToyLAIModel(), - Beer(0.6), - status=(TT_cu=cumsum(meteo_day[:, :TT]),), # Pass the cumulated degree-days as input to `ToyLAIModel`, this could also be done using another model -) - -# Run the simulation: -sim_outputs = run!(models, meteo_day) -sim_outputs[1:3,:] # show the first 3 rows of the output -``` - -The output data is displayed as a by default as a `TimeStepTable`. It is also possible to filter which variables are kept via the optional `tracked_outputs` keyword argument. - -## Plotting outputs - -Using CairoMakie, one can plot out selected variables : - -!!! note - You will need to add CairoMakie to your environment through Pkg mode first. - -```@example usepkg -# Plot the results: -using CairoMakie - -fig = Figure(resolution=(800, 600)) -ax = Axis(fig[1, 1], ylabel="LAI (m² m⁻²)") -lines!(ax, sim_outputs[:TT_cu], sim_outputs[:LAI], color=:mediumseagreen) - -ax2 = Axis(fig[2, 1], xlabel="Cumulated growing degree days since sowing (°C)", ylabel="aPPFD (mol m⁻² d⁻¹)") -lines!(ax2, sim_outputs[:TT_cu], sim_outputs[:aPPFD], color=:firebrick1) - -fig -``` - -## TimeStepTables and DataFrames - -```@setup usepkg -sim_out = run!(model, meteo_day) -``` - -The output data is usually stored in a `TimeStepTable` structure defined in `PlantMeteo.jl`, which is a fast DataFrame-like structure with each time step being a [`Status`](@ref). It can be also be any `Tables.jl` structure, such as a regular `DataFrame`. Weather data is also usually stored in a `TimeStepTable` but with each time step being an `Atmosphere`. - -Another simple way to get the results is to transform the outputs into a `DataFrame`. Which is very easy because the `TimeStepTable` implements the Tables.jl interface: - -```@example usepkg -using DataFrames -sim_outputs_df = PlantSimEngine.convert_outputs(sim_outputs, DataFrame) -sim_outputs_df[[1, 2, 3, 363, 364, 365], :] -``` - -It is also possible to create DataFrames from specific variables: - -```julia -df = DataFrame(aPPFD=sim_outputs[:aPPFD][1], LAI=sim_outputs.LAI[1], Ri_PAR_f=meteo.Ri_PAR_f[1]) -``` - -Which can also be useful for [Parameter fitting ](@ref). \ No newline at end of file diff --git a/examples/Beer.jl b/examples/Beer.jl index c7069c536..e0924acc2 100644 --- a/examples/Beer.jl +++ b/examples/Beer.jl @@ -21,9 +21,6 @@ struct Beer{T} <: AbstractLight_InterceptionModel k::T end -# Beer is parallelizable over time-steps and objects, so we can declare it as such using the trait: -PlantSimEngine.TimeStepDependencyTrait(::Type{<:Beer}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:Beer}) = PlantSimEngine.IsObjectIndependent() """ run!(::Beer, object, meteo, constants=Constants(), extra=nothing) @@ -35,8 +32,7 @@ of light extinction. # Arguments - `::Beer`: a Beer model, from the model list (*i.e.* m.light_interception) -- `models`: A `ModelMapping` struct holding the parameters for the model with -initialisations for `LAI` (m² m⁻²): the leaf area index. +- `models`: the process-keyed model bundle supplied by the model runtime. - `status`: the status of the model, usually the model list status (*i.e.* m.status) - `meteo`: meteorology structure, see [`Atmosphere`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.Atmosphere) - `constants = PlantMeteo.Constants()`: physical constants. See `PlantMeteo.Constants` for more details @@ -45,13 +41,22 @@ initialisations for `LAI` (m² m⁻²): the leaf area index. # Examples ```julia -m = ModelMapping(Beer(0.5), status=(LAI=2.0,)) - -meteo = Atmosphere(T=20.0, Wind=1.0, P=101.3, Rh=0.65, Ri_PAR_q=300.0) - -run!(m, meteo) - -m[:aPPFD] +model = CompositeModel( + Beer(0.5); + status=(LAI=2.0,), + id=:leaf, + scale=:Leaf, + environment=Atmosphere( + T=20.0, + Wind=1.0, + P=101.3, + Rh=0.65, + Ri_PAR_f=300.0, + duration=Hour(1), + ), +) +run!(model) +only(model_objects(model; scale=:Leaf)).status.aPPFD ``` """ function PlantSimEngine.run!(::Beer, models, status, meteo, constants, extra=nothing) @@ -95,14 +100,20 @@ using PlantSimEngine.Examples Create a model list with a Beer model, and fit it to the data: ```julia -m = ModelMapping(Beer(0.6), status=(LAI=2.0,)) -meteo = Atmosphere(T=20.0, Wind=1.0, P=101.3, Rh=0.65, Ri_PAR_f=300.0) -run!(m, meteo) -df = DataFrame(aPPFD=m[:aPPFD][1], LAI=m.status.LAI[1], Ri_PAR_f=meteo.Ri_PAR_f[1]) +model = CompositeModel( + Beer(0.6); + status=(LAI=2.0,), + id=:leaf, + scale=:Leaf, + environment=meteo, +) +run!(model) +leaf = only(model_objects(model; scale=:Leaf)) +df = DataFrame(aPPFD=leaf.status.aPPFD, LAI=leaf.status.LAI, Ri_PAR_f=meteo.Ri_PAR_f[1]) fit(Beer, df) ``` """ function PlantSimEngine.fit(::Type{Beer}, df; J_to_umol=PlantMeteo.Constants().J_to_umol) k = Statistics.mean(-log.(1 .- df.aPPFD ./ (J_to_umol .* df.Ri_PAR_f)) ./ df.LAI) return (k=k,) -end \ No newline at end of file +end diff --git a/examples/ToyAssimGrowthModel.jl b/examples/ToyAssimGrowthModel.jl index cda5f167f..dc33ce282 100644 --- a/examples/ToyAssimGrowthModel.jl +++ b/examples/ToyAssimGrowthModel.jl @@ -73,6 +73,3 @@ function PlantSimEngine.run!(::ToyAssimGrowthModel, models, status, meteo, const # The biomass is the biomass from the previous time-step plus the biomass increment: status.biomass += status.biomass_increment end - -# And optionally, we can tell PlantSimEngine that we can safely parallelize our model over space (objects): -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyAssimGrowthModel}) = PlantSimEngine.IsObjectIndependent() \ No newline at end of file diff --git a/examples/ToyAssimModel.jl b/examples/ToyAssimModel.jl index 7ad0f2ed8..8e6a04997 100644 --- a/examples/ToyAssimModel.jl +++ b/examples/ToyAssimModel.jl @@ -55,8 +55,3 @@ function PlantSimEngine.run!(::ToyAssimModel, models, status, meteo, constants, # The assimilation is simply the absorbed photosynthetic photon flux density (aPPFD) times the light use efficiency (LUE): status.carbon_assimilation = status.aPPFD * models.carbon_assimilation.LUE * status.soil_water_content end - -# And optionally, we can tell PlantSimEngine that we can safely parallelize our model over space (objects): -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyAssimModel}) = PlantSimEngine.IsObjectIndependent() -# And also over time (time-steps): -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyAssimModel}) = PlantSimEngine.IsTimeStepIndependent() \ No newline at end of file diff --git a/examples/ToyCAllocationModel.jl b/examples/ToyCAllocationModel.jl index db6666152..afb060aa5 100644 --- a/examples/ToyCAllocationModel.jl +++ b/examples/ToyCAllocationModel.jl @@ -68,5 +68,5 @@ function PlantSimEngine.run!(::ToyCAllocationModel, models, status, meteo, const end end -# Can be parallelized over time-steps, but not objects (we have vectors of values coming from other objects as input): -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyCAllocationModel}) = PlantSimEngine.IsTimeStepIndependent() +# This model reads values from several objects, so object-level independence +# must not be assumed by a future executor. diff --git a/examples/ToyCBiomassModel.jl b/examples/ToyCBiomassModel.jl index d2dd19d8d..db11843dc 100644 --- a/examples/ToyCBiomassModel.jl +++ b/examples/ToyCBiomassModel.jl @@ -41,6 +41,3 @@ function PlantSimEngine.run!(m::ToyCBiomassModel, models, status, meteo, constan status.carbon_biomass += status.carbon_biomass_increment status.growth_respiration = status.carbon_allocation - status.carbon_biomass_increment end - -# Can be parallelized over organs (but not time-steps, as it is incrementally updating the biomass in the status): -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyCBiomassModel}) = PlantSimEngine.IsObjectIndependent() \ No newline at end of file diff --git a/examples/ToyCDemandModel.jl b/examples/ToyCDemandModel.jl index b376aa83c..77cfe9f06 100644 --- a/examples/ToyCDemandModel.jl +++ b/examples/ToyCDemandModel.jl @@ -52,8 +52,3 @@ function PlantSimEngine.run!(::ToyCDemandModel, models, status, meteo, constants # The carbon demand is simply the biomass under optimal conditions divided by the duration of the development: status.carbon_demand = status.TT * models.carbon_demand.optimal_biomass / models.carbon_demand.development_duration end - -# And optionally, we can tell PlantSimEngine that we can safely parallelize our model over space (objects): -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyCDemandModel}) = PlantSimEngine.IsObjectIndependent() -# And also over time (time-steps): -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyCDemandModel}) = PlantSimEngine.IsTimeStepIndependent() \ No newline at end of file diff --git a/examples/ToyDegreeDays.jl b/examples/ToyDegreeDays.jl index 72d77b70c..e8621cb30 100644 --- a/examples/ToyDegreeDays.jl +++ b/examples/ToyDegreeDays.jl @@ -29,8 +29,3 @@ function PlantSimEngine.run!(m::ToyDegreeDaysCumulModel, models, status, meteo, status.TT = max(0.0, min(meteo.T, m.T_max) - m.T_base) status.TT_cu += status.TT end - -# The computation of ToyDegreeDaysCumulModel dependents on previous values, but it is independent of other objects. -# The default trait is that models are dependent of other time-steps and object. So we need to change the default trait -# for objects: -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyDegreeDaysCumulModel}) = PlantSimEngine.IsObjectIndependent() \ No newline at end of file diff --git a/examples/ToyInternodeEmergence.jl b/examples/ToyInternodeEmergence.jl deleted file mode 100644 index b43cfd58d..000000000 --- a/examples/ToyInternodeEmergence.jl +++ /dev/null @@ -1,36 +0,0 @@ - -# Declaring the process of LAI dynamic: -PlantSimEngine.@process "organ_emergence" verbose = false - -# Declaring the model of LAI dynamic with its parameter values: - -""" - ToyInternodeEmergence(;init_TT=0.0, TT_emergence = 300) - -Computes the organ emergence based on cumulated thermal time since last event. -""" -struct ToyInternodeEmergence <: AbstractOrgan_EmergenceModel - TT_emergence::Float64 -end - -# Defining default values: -ToyInternodeEmergence(; TT_emergence=300.0) = ToyInternodeEmergence(TT_emergence) - -# Defining the inputs and outputs of the model: -PlantSimEngine.inputs_(m::ToyInternodeEmergence) = (TT_cu=-Inf,) -PlantSimEngine.outputs_(m::ToyInternodeEmergence) = (TT_cu_emergence=0.0,) - -# Implementing the actual algorithm by adding a method to the run! function for our model: -function PlantSimEngine.run!(m::ToyInternodeEmergence, models, status, meteo, constants=nothing, sim_object=nothing) - - if length(MultiScaleTreeGraph.children(status.node)) == 1 && status.TT_cu - status.TT_cu_emergence >= m.TT_emergence - # NB: the node can produce one leaf, and one internode only, so we check that it did not produce - # any internode yet. - status_new_internode = add_organ!(status.node, sim_object, "<", :Internode, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - - status_new_internode.TT_cu_emergence = status.TT_cu - end - - return nothing -end \ No newline at end of file diff --git a/examples/ToyLAIModel.jl b/examples/ToyLAIModel.jl index 081c8824f..3ca29ee5c 100644 --- a/examples/ToyLAIModel.jl +++ b/examples/ToyLAIModel.jl @@ -55,22 +55,18 @@ function PlantSimEngine.run!(::ToyLAIModel, models, status, meteo, constants=not end end -# The computation of ToyLAIModel is independant of previous values and other objects. We can add this information as -# traits to the model to tell PlantSimEngine that it is safe to run the models in parallel: -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyLAIModel}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyLAIModel}) = PlantSimEngine.IsObjectIndependent() +# ToyLAIModel is independent of previous values and other objects. The current +# public runtime remains sequential and owns execution policy. - - -# A second model at scene scale: +# A second model at model scale: """ ToyLAIfromLeafAreaModel() -Computes the Leaf Area Index (LAI) of the scene based on the plants leaf area. +Computes the Leaf Area Index (LAI) of the model based on the plants leaf area. # Arguments -- `scene_area`: the area of the scene, usually in m² +- `scene_area`: the area of the model, usually in m² # Inputs @@ -78,7 +74,7 @@ Computes the Leaf Area Index (LAI) of the scene based on the plants leaf area. # Outputs -- `LAI`: the Leaf Area Index of the scene, usually in m² m⁻² +- `LAI`: the Leaf Area Index of the model, usually in m² m⁻² - `total_surface`: the total surface of the plants, usually in m² """ struct ToyLAIfromLeafAreaModel{T} <: AbstractLai_DynamicModel @@ -95,5 +91,5 @@ function PlantSimEngine.run!(m::ToyLAIfromLeafAreaModel, models, status, meteo, status.LAI = status.total_surface / m.scene_area end -# The computation of ToyLAIfromLeafAreaModel is independant of previous values so we can compute it in parallel over time-steps: -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyLAIfromLeafAreaModel}) = PlantSimEngine.IsTimeStepIndependent() +# ToyLAIfromLeafAreaModel is independent of previous values, but execution +# policy remains owned by the runtime. diff --git a/examples/ToyLeafSurfaceModel.jl b/examples/ToyLeafSurfaceModel.jl index b120745c4..268c87ec3 100644 --- a/examples/ToyLeafSurfaceModel.jl +++ b/examples/ToyLeafSurfaceModel.jl @@ -40,8 +40,6 @@ function PlantSimEngine.run!(m::ToyLeafSurfaceModel, models, status, meteo, cons end # Can be parallelized over organs and time-steps: -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyLeafSurfaceModel}) = PlantSimEngine.IsObjectIndependent() -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyLeafSurfaceModel}) = PlantSimEngine.IsTimeStepDependent() @@ -75,6 +73,3 @@ end function PlantSimEngine.run!(m::ToyPlantLeafSurfaceModel, models, status, meteo, constants, extra_args) status.surface = sum(status.leaf_surfaces) end - -# Can be parallelized over time-steps: -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyPlantLeafSurfaceModel}) = PlantSimEngine.IsTimeStepDependent() \ No newline at end of file diff --git a/examples/ToyLightPartitioningModel.jl b/examples/ToyLightPartitioningModel.jl index 3f43c009f..985e64644 100644 --- a/examples/ToyLightPartitioningModel.jl +++ b/examples/ToyLightPartitioningModel.jl @@ -11,7 +11,7 @@ Computes the light partitioning based on relative surface. # Inputs -- `aPPFD`: the absorbed photosynthetic photon flux density at the larger scale (*e.g.* scene), in mol[PAR] m⁻² time-step⁻¹ +- `aPPFD`: the absorbed photosynthetic photon flux density at the larger scale (*e.g.* model), in mol[PAR] m⁻² time-step⁻¹ # Outputs @@ -32,5 +32,3 @@ PlantSimEngine.outputs_(::ToyLightPartitioningModel) = (aPPFD=-Inf,) function PlantSimEngine.run!(::ToyLightPartitioningModel, models, status, meteo, constants, extra) status.aPPFD = status.aPPFD_larger_scale * status.surface / status.total_surface end - -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyLightPartitioningModel}) = PlantSimEngine.IsTimeStepIndependent() \ No newline at end of file diff --git a/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation1.jl b/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation1.jl deleted file mode 100644 index 120ad9f95..000000000 --- a/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation1.jl +++ /dev/null @@ -1,140 +0,0 @@ - -########################################### -# Toy plant model -# Physiologically meaningless but illustrates organ creation -########################################### - -function get_n_leaves(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - nleaves = length(MultiScaleTreeGraph.traverse(root, x -> 1, symbol=:Leaf)) - return nleaves -end - -PlantSimEngine.@process "organ_emergence" verbose = false - -struct ToyCustomInternodeEmergence{T} <: AbstractOrgan_EmergenceModel - TT_emergence::T - carbon_internode_creation_cost::T - leaf_surface_area::T - leaves_max_surface_area::T -end - -ToyCustomInternodeEmergence(; TT_emergence=300.0, carbon_internode_creation_cost=200.0, leaf_surface_area=3.0, leaves_max_surface_area=100.0) = ToyCustomInternodeEmergence(TT_emergence, carbon_internode_creation_cost, leaf_surface_area, leaves_max_surface_area) - -PlantSimEngine.inputs_(m::ToyCustomInternodeEmergence) = (TT_cu=0.0, carbon_stock=0.0) -PlantSimEngine.outputs_(m::ToyCustomInternodeEmergence) = (TT_cu_emergence=0.0, carbon_organ_creation_consumed=0.0) - -function PlantSimEngine.run!(m::ToyCustomInternodeEmergence, models, status, meteo, constants=nothing, sim_object=nothing) - - leaves_surface_area = m.leaf_surface_area * get_n_leaves(status.node) - status.carbon_organ_creation_consumed = 0.0 - - if leaves_surface_area > m.leaves_max_surface_area - return nothing - end - - # if not enough carbon, no organ creation - if status.carbon_stock < m.carbon_internode_creation_cost - return nothing - end - - if length(MultiScaleTreeGraph.children(status.node)) == 2 && - status.TT_cu - status.TT_cu_emergence >= m.TT_emergence - status_new_internode = add_organ!(status.node, sim_object, "<", :Internode, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - - status_new_internode.TT_cu_emergence = m.TT_emergence - status.TT_cu - status.carbon_organ_creation_consumed = m.carbon_internode_creation_cost - end - - return nothing -end - -########################## -### Model accumulating carbon resources -########################## - -PlantSimEngine.@process "resource_stock_computation" verbose = false - -struct ToyStockComputationModel <: AbstractResource_Stock_ComputationModel -end - -PlantSimEngine.inputs_(::ToyStockComputationModel) = - (carbon_captured=0.0, carbon_organ_creation_consumed=0.0) - -PlantSimEngine.outputs_(::ToyStockComputationModel) = (carbon_stock=-Inf,) - -function PlantSimEngine.run!(m::ToyStockComputationModel, models, status, meteo, constants=nothing, extra=nothing) - status.carbon_stock += sum(status.carbon_captured) - sum(status.carbon_organ_creation_consumed) -end - -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyStockComputationModel}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyStockComputationModel}) = PlantSimEngine.IsObjectIndependent() - -######################## -## Leaf model capturing some arbitrary carbon quantity -######################## - -PlantSimEngine.@process "leaf_carbon_capture" verbose = false - -struct ToyLeafCarbonCaptureModel <: AbstractLeaf_Carbon_CaptureModel end - -function PlantSimEngine.inputs_(::ToyLeafCarbonCaptureModel) - NamedTuple()#(TT_cu=-Inf) -end - -function PlantSimEngine.outputs_(::ToyLeafCarbonCaptureModel) - (carbon_captured=0.0,) -end - -function PlantSimEngine.run!(::ToyLeafCarbonCaptureModel, models, status, meteo, constants, extra) - # very crude approximation with LAI of 1 and constant aPPFD - status.carbon_captured = 200.0 * (1.0 - exp(-0.2)) -end - -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyLeafCarbonCaptureModel}) = PlantSimEngine.IsObjectIndependent() -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyLeafCarbonCaptureModel}) = PlantSimEngine.IsTimeStepIndependent() - -mapping = ModelMapping( - :Scene => ToyDegreeDaysCumulModel(), - :Plant => ( - MultiScaleModel( - model=ToyStockComputationModel(), - mapped_variables=[ - :carbon_captured => [:Leaf], - :carbon_organ_creation_consumed => [:Internode] - ], - ), - Status(carbon_stock=0.0) - ), - :Internode => ( - MultiScaleModel( - model=ToyCustomInternodeEmergence(),#TT_emergence=20.0), - mapped_variables=[:TT_cu => (:Scene => :TT_cu), - PreviousTimeStep(:carbon_stock) => (:Plant => :carbon_stock)], - ), - Status(carbon_organ_creation_consumed=0.0), - ), - :Leaf => (ToyLeafCarbonCaptureModel(),), -) - -mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) -#MultiScaleTreeGraph.Node(mtg, MultiScaleTreeGraph.NodeMTG("/", :Soil, 1, 1)) -plant = MultiScaleTreeGraph.Node(mtg, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - -internode1 = MultiScaleTreeGraph.Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) -MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) -MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - -internode2 = MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("<", :Internode, 1, 2)) -MultiScaleTreeGraph.Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) -MultiScaleTreeGraph.Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - - -meteo_day = CSV.read(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), DataFrame, header=18) - -outs = run!(mtg, mapping, meteo_day) -mtg - -length(MultiScaleTreeGraph.traverse(mtg, x -> x, symbol=:Leaf)) diff --git a/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation2.jl b/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation2.jl deleted file mode 100644 index 15b3ec356..000000000 --- a/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation2.jl +++ /dev/null @@ -1,235 +0,0 @@ -########################################### -# Toy plant model -# Physiologically and physically completely meaningless -# (no dimension for units, arbitrary values, stores water and carbon in abstract stocks, -# arbitrary max leaf count and root length, constant and non-coupled photosynthesis and water absorption, ...) -# But it should illustrate the basics of simulating a growing multiscale plant with PlantSimEngine's model approach -########################################### - -function get_root_end_node(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - return MultiScaleTreeGraph.traverse(root, x -> x, symbol=:Root, filter_fun=MultiScaleTreeGraph.isleaf) -end - -function get_roots_count(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - return length(MultiScaleTreeGraph.traverse(root, x -> x, symbol=:Root)) -end - -function get_n_leaves(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - nleaves = length(MultiScaleTreeGraph.traverse(root, x -> 1, symbol=:Leaf)) - return nleaves -end - -PlantSimEngine.@process "organ_emergence" verbose = false - -struct ToyCustomInternodeEmergence{T} <: AbstractOrgan_EmergenceModel - TT_emergence::T - carbon_internode_creation_cost::T - leaf_surface_area::T - leaves_max_surface_area::T - water_leaf_threshold::T -end - -ToyCustomInternodeEmergence(; TT_emergence=300.0, carbon_internode_creation_cost=200.0, leaf_surface_area=3.0, leaves_max_surface_area=100.0, - water_leaf_threshold=30.0) = ToyCustomInternodeEmergence(TT_emergence, carbon_internode_creation_cost, leaf_surface_area, leaves_max_surface_area, water_leaf_threshold) - -PlantSimEngine.inputs_(m::ToyCustomInternodeEmergence) = (TT_cu=0.0, water_stock=0.0, carbon_stock=0.0) -PlantSimEngine.outputs_(m::ToyCustomInternodeEmergence) = (TT_cu_emergence=0.0, carbon_organ_creation_consumed=0.0) - -function PlantSimEngine.run!(m::ToyCustomInternodeEmergence, models, status, meteo, constants=nothing, sim_object=nothing) - - leaves_surface_area = m.leaf_surface_area * get_n_leaves(status.node) - status.carbon_organ_creation_consumed = 0.0 - - if leaves_surface_area > m.leaves_max_surface_area - return nothing - end - - # if water levels are low, prioritise roots - if status.water_stock < m.water_leaf_threshold - return nothing - end - - # if not enough carbon, no organ creation - if status.carbon_stock < m.carbon_internode_creation_cost - return nothing - end - - if length(MultiScaleTreeGraph.children(status.node)) == 2 && - status.TT_cu - status.TT_cu_emergence >= m.TT_emergence - status_new_internode = add_organ!(status.node, sim_object, "<", :Internode, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - - status_new_internode.TT_cu_emergence = m.TT_emergence - status.TT_cu - status.carbon_organ_creation_consumed = m.carbon_internode_creation_cost - end - - return nothing -end - -############################ -# Naive water absorption model -# Absorbs precipitation water depending on quantity of roots -############################ -PlantSimEngine.@process "water_absorption" verbose = false - -struct ToyWaterAbsorptionModel <: AbstractWater_AbsorptionModel -end - -PlantSimEngine.inputs_(::ToyWaterAbsorptionModel) = (root_water_assimilation=1.0,) -PlantSimEngine.outputs_(::ToyWaterAbsorptionModel) = (water_absorbed=0.0,) - -function PlantSimEngine.run!(m::ToyWaterAbsorptionModel, models, status, meteo, constants=nothing, extra=nothing) - #root_end = get_root_end_node(status.node) - #root_len = root_end[:Root_len] - status.water_absorbed = meteo.Precipitations * status.root_water_assimilation #* root_len -end - -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyWaterAbsorptionModel}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyWaterAbsorptionModel}) = PlantSimEngine.IsObjectIndependent() - - -########################## -### Root growth : when water stocks are low, expand root -########################## - -PlantSimEngine.@process "root_growth" verbose = false - -struct ToyRootGrowthModel{T} <: AbstractRoot_GrowthModel - water_threshold::T - carbon_root_creation_cost::T - root_max_len::Int -end - -PlantSimEngine.inputs_(::ToyRootGrowthModel) = (water_stock=0.0, carbon_stock=0.0,) -PlantSimEngine.outputs_(::ToyRootGrowthModel) = (carbon_root_creation_consumed=0.0,) - -function PlantSimEngine.run!(m::ToyRootGrowthModel, models, status, meteo, constants=nothing, extra=nothing) - if status.water_stock < m.water_threshold && status.carbon_stock > m.carbon_root_creation_cost - - root_end = get_root_end_node(status.node) - - if length(root_end) != 1 - throw(AssertionError("Couldn't find MTG leaf node with symbol \"Root\"")) - end - root_len = get_roots_count(root_end[1]) - if root_len < m.root_max_len - st = add_organ!(root_end[1], extra, "<", :Root, 2, index=1) - status.carbon_root_creation_consumed = m.carbon_root_creation_cost - end - else - status.carbon_root_creation_consumed = 0.0 - end -end - -########################## -### Model accumulating carbon and water resources -########################## - -PlantSimEngine.@process "resource_stock_computation" verbose = false - -struct ToyStockComputationModel <: AbstractResource_Stock_ComputationModel -end -#status.water_stock += meteo.precipitations * root_water_assimilation_ratio - -PlantSimEngine.inputs_(::ToyStockComputationModel) = - (water_absorbed=0.0, carbon_captured=0.0, carbon_organ_creation_consumed=0.0, carbon_root_creation_consumed=0.0) - -PlantSimEngine.outputs_(::ToyStockComputationModel) = (water_stock=-Inf, carbon_stock=-Inf) - -function PlantSimEngine.run!(m::ToyStockComputationModel, models, status, meteo, constants=nothing, extra=nothing) - status.water_stock += sum(status.water_absorbed) #- status.water_transpiration - status.carbon_stock += sum(status.carbon_captured) - sum(status.carbon_organ_creation_consumed) - sum(status.carbon_root_creation_consumed) - - if status.water_stock < 0.0 - status.water_stock = 0.0 - end -end - -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyStockComputationModel}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyStockComputationModel}) = PlantSimEngine.IsObjectIndependent() - -######################## -## Leaf model capturing some arbitrary carbon quantity -######################## - -PlantSimEngine.@process "leaf_carbon_capture" verbose = false - -struct ToyLeafCarbonCaptureModel <: AbstractLeaf_Carbon_CaptureModel end - -function PlantSimEngine.inputs_(::ToyLeafCarbonCaptureModel) - NamedTuple()#(TT_cu=-Inf) -end - -function PlantSimEngine.outputs_(::ToyLeafCarbonCaptureModel) - (carbon_captured=0.0,) -end - -function PlantSimEngine.run!(::ToyLeafCarbonCaptureModel, models, status, meteo, constants, extra) - # very crude approximation with LAI of 1 and constant aPPFD - status.carbon_captured = 200.0 * (1.0 - exp(-0.2)) -end - -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyLeafCarbonCaptureModel}) = PlantSimEngine.IsObjectIndependent() -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyLeafCarbonCaptureModel}) = PlantSimEngine.IsTimeStepIndependent() - -mapping = ModelMapping( - :Scene => ToyDegreeDaysCumulModel(), - :Plant => ( - MultiScaleModel( - model=ToyStockComputationModel(), - mapped_variables=[ - :carbon_captured => [:Leaf], - :water_absorbed => [:Root], - :carbon_root_creation_consumed => [:Root], - :carbon_organ_creation_consumed => [:Internode]], - ), - Status(water_stock=0.0, carbon_stock=0.0) - ), - :Internode => ( - MultiScaleModel( - model=ToyCustomInternodeEmergence(),#TT_emergence=20.0), - mapped_variables=[:TT_cu => (:Scene => :TT_cu), - PreviousTimeStep(:water_stock) => (:Plant => :water_stock), - PreviousTimeStep(:carbon_stock) => (:Plant => :carbon_stock)], - ), - Status(carbon_organ_creation_consumed=0.0), - ), - :Root => (MultiScaleModel( - model=ToyRootGrowthModel(10.0, 50.0, 10), - mapped_variables=[PreviousTimeStep(:carbon_stock) => (:Plant => :carbon_stock), - PreviousTimeStep(:water_stock) => (:Plant => :water_stock)], - ), - ToyWaterAbsorptionModel(), - Status(carbon_root_creation_consumed=0.0, root_water_assimilation=1.0), - ), - :Leaf => (ToyLeafCarbonCaptureModel(),), -) - -mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) - -plant = MultiScaleTreeGraph.Node(mtg, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - -internode1 = MultiScaleTreeGraph.Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) -MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) -MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - -internode2 = MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("<", :Internode, 1, 2)) -MultiScaleTreeGraph.Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) -MultiScaleTreeGraph.Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - -plant_root_start = MultiScaleTreeGraph.Node( - plant, - MultiScaleTreeGraph.NodeMTG("+", :Root, 1, 3), -) - -meteo_day = CSV.read(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), DataFrame, header=18) - -outs = run!(mtg, mapping, meteo_day) -mtg - - -length(MultiScaleTreeGraph.traverse(mtg, x -> x, symbol=:Leaf)) diff --git a/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation3.jl b/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation3.jl deleted file mode 100644 index bdd4071b5..000000000 --- a/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation3.jl +++ /dev/null @@ -1,251 +0,0 @@ -########################################### -# Toy plant model with an updated decision model for organ growth -# Physiologically and physically completely meaningless -# (no dimension for units, arbitrary values, stores water and carbon in abstract stocks, -# arbitrary max leaf count and root length, constant and non-coupled photosynthesis and water absorption, ...) -# But it should illustrate the basics of simulating a growing multiscale plant with PlantSimEngine's model approach -########################################### - -function get_root_end_node(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - return MultiScaleTreeGraph.traverse(root, x -> x, symbol=:Root, filter_fun=MultiScaleTreeGraph.isleaf) -end - -function get_roots_count(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - return length(MultiScaleTreeGraph.traverse(root, x -> x, symbol=:Root)) -end - -function get_n_leaves(node::MultiScaleTreeGraph.Node) - root = MultiScaleTreeGraph.get_root(node) - nleaves = length(MultiScaleTreeGraph.traverse(root, x -> 1, symbol=:Leaf)) - return nleaves -end - -PlantSimEngine.@process "organ_emergence" verbose = false - -struct ToyCustomInternodeEmergence{T} <: AbstractOrgan_EmergenceModel - TT_emergence::T - carbon_internode_creation_cost::T - leaf_surface_area::T - leaves_max_surface_area::T - water_leaf_threshold::T -end - -ToyCustomInternodeEmergence(; TT_emergence=300.0, carbon_internode_creation_cost=200.0, leaf_surface_area=3.0, leaves_max_surface_area=100.0, - water_leaf_threshold=30.0) = ToyCustomInternodeEmergence(TT_emergence, carbon_internode_creation_cost, leaf_surface_area, leaves_max_surface_area, water_leaf_threshold) - -PlantSimEngine.inputs_(m::ToyCustomInternodeEmergence) = (TT_cu=0.0, water_stock=0.0, carbon_stock=0.0, carbon_root_creation_consumed=0.0) -PlantSimEngine.outputs_(m::ToyCustomInternodeEmergence) = (TT_cu_emergence=0.0, carbon_organ_creation_consumed=0.0) - -function PlantSimEngine.run!(m::ToyCustomInternodeEmergence, models, status, meteo, constants=nothing, sim_object=nothing) - - leaves_surface_area = m.leaf_surface_area * get_n_leaves(status.node) - status.carbon_organ_creation_consumed = 0.0 - - if leaves_surface_area > m.leaves_max_surface_area - return nothing - end - - # if water levels are low, prioritise roots - if status.water_stock < m.water_leaf_threshold - return nothing - end - - # take into account that the stock may already be depleted - carbon_stock_updated_after_roots = status.carbon_stock - status.carbon_root_creation_consumed - - # if not enough carbon, no organ creation - if carbon_stock_updated_after_roots < m.carbon_internode_creation_cost - return nothing - end - - if length(MultiScaleTreeGraph.children(status.node)) == 2 && - status.TT_cu - status.TT_cu_emergence >= m.TT_emergence - status_new_internode = add_organ!(status.node, sim_object, "<", :Internode, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - add_organ!(status_new_internode.node, sim_object, "+", :Leaf, 2, index=1) - - status_new_internode.TT_cu_emergence = m.TT_emergence - status.TT_cu - status.carbon_organ_creation_consumed = m.carbon_internode_creation_cost - end - - return nothing -end - -############################ -# Naive water absorption model -# Absorbs precipitation water depending on quantity of roots -############################ -PlantSimEngine.@process "water_absorption" verbose = false - -struct ToyWaterAbsorptionModel <: AbstractWater_AbsorptionModel -end - -PlantSimEngine.inputs_(::ToyWaterAbsorptionModel) = (root_water_assimilation=1.0,) -PlantSimEngine.outputs_(::ToyWaterAbsorptionModel) = (water_absorbed=0.0,) - -function PlantSimEngine.run!(m::ToyWaterAbsorptionModel, models, status, meteo, constants=nothing, extra=nothing) - #root_end = get_root_end_node(status.node) - #root_len = root_end[:Root_len] - status.water_absorbed = meteo.Precipitations * status.root_water_assimilation #* root_len -end - -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyWaterAbsorptionModel}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyWaterAbsorptionModel}) = PlantSimEngine.IsObjectIndependent() - - -########################## -### Root growth : when water stocks are low, expand root -########################## - -PlantSimEngine.@process "root_growth" verbose = false - -struct ToyRootGrowthModel{T} <: AbstractRoot_GrowthModel - carbon_root_creation_cost::T - root_max_len::Int -end - -PlantSimEngine.inputs_(::ToyRootGrowthModel) = NamedTuple() -PlantSimEngine.outputs_(::ToyRootGrowthModel) = (carbon_root_creation_consumed=0.0,) - -function PlantSimEngine.run!(m::ToyRootGrowthModel, models, status, meteo, constants=nothing, extra=nothing) - status.carbon_root_creation_consumed = 0.0 - - root_end = get_root_end_node(status.node) - - if length(root_end) != 1 - throw(AssertionError("Couldn't find MTG leaf node with symbol \"Root\"")) - end - - root_len = get_roots_count(root_end[1]) - if root_len < m.root_max_len - st = add_organ!(root_end[1], extra, "<", :Root, 2, index=1) - status.carbon_root_creation_consumed = m.carbon_root_creation_cost - end -end - -########################## -### Decision model controlling the root growth model -########################## -PlantSimEngine.@process "root_growth_decision" verbose = false - -struct ToyRootGrowthDecisionModel{T} <: AbstractRoot_Growth_DecisionModel - water_threshold::T - carbon_root_creation_cost::T -end - -PlantSimEngine.inputs_(::ToyRootGrowthDecisionModel) = - (water_stock=0.0, carbon_stock=0.0) - -PlantSimEngine.outputs_(::ToyRootGrowthDecisionModel) = NamedTuple() - -PlantSimEngine.dep(::ToyRootGrowthDecisionModel) = (root_growth=AbstractRoot_GrowthModel => [:Root],) - -function PlantSimEngine.run!(m::ToyRootGrowthDecisionModel, models, status, meteo, constants=nothing, extra=nothing) - - if status.water_stock < m.water_threshold && status.carbon_stock > m.carbon_root_creation_cost - status_Root = extra.statuses[:Root][1] - PlantSimEngine.run!(extra.models[:Root].root_growth, models, status_Root, meteo, constants, extra) - end -end - - -########################## -### Model accumulating carbon and water resources -########################## - -PlantSimEngine.@process "resource_stock_computation" verbose = false - -struct ToyStockComputationModel <: AbstractResource_Stock_ComputationModel -end - -PlantSimEngine.inputs_(::ToyStockComputationModel) = - (water_absorbed=0.0, carbon_captured=0.0, carbon_organ_creation_consumed=0.0, carbon_root_creation_consumed=0.0) - -PlantSimEngine.outputs_(::ToyStockComputationModel) = (water_stock=-Inf, carbon_stock=-Inf) - -function PlantSimEngine.run!(m::ToyStockComputationModel, models, status, meteo, constants=nothing, extra=nothing) - status.water_stock += sum(status.water_absorbed) - status.carbon_stock += sum(status.carbon_captured) - sum(status.carbon_organ_creation_consumed) - sum(status.carbon_root_creation_consumed) -end - - -######################## -## Leaf model capturing some arbitrary carbon quantity -######################## - -PlantSimEngine.@process "leaf_carbon_capture" verbose = false - -struct ToyLeafCarbonCaptureModel <: AbstractLeaf_Carbon_CaptureModel end - -function PlantSimEngine.inputs_(::ToyLeafCarbonCaptureModel) - NamedTuple() -end - -function PlantSimEngine.outputs_(::ToyLeafCarbonCaptureModel) - (carbon_captured=0.0,) -end - -function PlantSimEngine.run!(::ToyLeafCarbonCaptureModel, models, status, meteo, constants, extra) - # very crude approximation with LAI of 1 and constant aPPFD - status.carbon_captured = 200.0 * (1.0 - exp(-0.2)) -end - - -mapping = ModelMapping( - :Scene => ToyDegreeDaysCumulModel(), - :Plant => ( - MultiScaleModel( - model=ToyStockComputationModel(), - mapped_variables=[ - :carbon_captured => [:Leaf], - :water_absorbed => [:Root], - PreviousTimeStep(:carbon_root_creation_consumed) => (:Root => :carbon_root_creation_consumed), - PreviousTimeStep(:carbon_organ_creation_consumed) => [:Internode], - ], - ), - ToyRootGrowthDecisionModel(10.0, 50.0), - Status(water_stock=0.0, carbon_stock=0.0) - ), - :Internode => ( - MultiScaleModel( - model=ToyCustomInternodeEmergence(),#TT_emergence=20.0), - mapped_variables=[:TT_cu => (:Scene => :TT_cu), - :water_stock => (:Plant => :water_stock), - :carbon_stock => (:Plant => :carbon_stock), - :carbon_root_creation_consumed => (:Root => :carbon_root_creation_consumed)], - ), - Status(carbon_organ_creation_consumed=0.0), - ), - :Root => (ToyRootGrowthModel(50.0, 10), - ToyWaterAbsorptionModel(), - Status(carbon_root_creation_consumed=0.0, root_water_assimilation=1.0), - ), - :Leaf => (ToyLeafCarbonCaptureModel(),), -) - -mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) - -plant = MultiScaleTreeGraph.Node(mtg, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - -internode1 = MultiScaleTreeGraph.Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) -MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) -MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - -internode2 = MultiScaleTreeGraph.Node(internode1, MultiScaleTreeGraph.NodeMTG("<", :Internode, 1, 2)) -MultiScaleTreeGraph.Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) -MultiScaleTreeGraph.Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - -plant_root_start = MultiScaleTreeGraph.Node( - plant, - MultiScaleTreeGraph.NodeMTG("+", :Root, 1, 3), -) - -meteo_day = CSV.read(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), DataFrame, header=18) - -outs = run!(mtg, mapping, meteo_day) -mtg - - -length(MultiScaleTreeGraph.traverse(mtg, x -> x, symbol=:Leaf)) diff --git a/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation4.jl b/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation4.jl deleted file mode 100644 index 1684ab44e..000000000 --- a/examples/ToyMultiScalePlantTutorial/ToyPlantSimulation4.jl +++ /dev/null @@ -1,148 +0,0 @@ -########################################### -# Toy plant model MTG visualisation using PlantGeom -########################################### -using PlantSimEngine - -using MultiScaleTreeGraph -using PlantSimEngine.Examples -using Pkg -Pkg.add("CSV") -using CSV -include("ToyPlantSimulation3.jl") - -using Plots -using PlantGeom -# reusing the mtg from part 3: -RecipesBase.plot(mtg) - -#= -using GLMakie -#using CairoMakie -using PlantGeom - -PlantGeom.diagram(mtg)=# - - -using PlantGeom.Meshes - -# Internodes and roots will use a cylinder as a mesh - -cylinder() = Meshes.CylinderSurface(1.0) |> Meshes.discretize |> Meshes.simplexify - -refmesh_internode = PlantGeom.RefMesh("Internode", cylinder()) -refmesh_root = PlantGeom.RefMesh("Root", cylinder()) - -# Leaves and petioles are a single mesh, read from a .ply file - -Pkg.add("PlyIO") -using PlyIO -function read_ply(fname) - ply = PlyIO.load_ply(fname) - x = ply["vertex"]["x"] - y = ply["vertex"]["y"] - z = ply["vertex"]["z"] - points = Meshes.Point.(x, y, z) - connec = [Meshes.connect(Tuple(c .+ 1)) for c in ply["face"]["vertex_indices"]] - Meshes.SimpleMesh(points, connec) -end - -leaf_ply = read_ply("examples/leaf_with_petiole.ply") -refmesh_leaf = PlantGeom.RefMesh("Leaf", leaf_ply) - -Pkg.add("TransformsBase") -Pkg.add("Rotations") -#using PlantGeom.TranformsBase -import TransformsBase: → -import Rotations: RotY, RotZ, RotX -# Add the geometry to the MTG, with transformations -function add_geometry!(mtg, refmesh_internode) - - # incremental offset - internode_height = 0.0 - - # relative scale of the base mesh - internode_width = 0.5 - - # length of the base mesh - internode_length = 1.0 - - traverse!(mtg) do node - if symbol(node) == :Internode - # Set to scale, then translate by the total height - mesh_transformation = Meshes.Scale(internode_width, internode_width, internode_length) → Meshes.Translate(0.0, 0.0, internode_height) - node.geometry = PlantGeom.Geometry(ref_mesh=refmesh_internode, transformation=mesh_transformation) - - internode_height += internode_length - end - end -end - -add_geometry!(mtg, refmesh_internode) - -# Visualize the mesh -using GLMakie -viz(mtg) - -function add_geometry!(mtg, refmesh_internode, refmesh_root, refmesh_leaf) - - # incremental offset - internode_height = 0.0 - root_depth = 0.0 - - # relative scale of the base mesh - internode_width = 0.5 - root_width = 0.2 - - # length of the base mesh - internode_length = 1.0 - root_length = 1.0 - - # ad hoc value to adjust the base mesh to the scene scale - leaf_mesh_scale = 25 - leaf_scale_width = 0.4*leaf_mesh_scale - leaf_scale_height = 0.4*leaf_mesh_scale - - # Helpers to make the leaves opposite decussate - leaf_rotation = MathConstants.pi / 2.0 - i = 0 - - traverse!(mtg) do node - if symbol(node) == :Internode - # Set to scale, then translate by the total height - mesh_transformation = Meshes.Scale(internode_width, internode_width, internode_length) → Meshes.Translate(0.0, 0.0, internode_height) - node.geometry = PlantGeom.Geometry(ref_mesh=refmesh_internode, transformation=mesh_transformation) - - internode_height += node_length - - # Leaves are placed relatively to the parent internode - for chnode in children(node) - if symbol(chnode) == :Leaf - # Leaves are placed halfway along the the parent internode - mesh_transformation = Meshes.Scale(leaf_scale_width, leaf_scale_width, leaf_scale_height) → Meshes.Rotate(RotX(-MathConstants.pi / 6.0)) → Meshes.Translate(0.0, -internode_width, internode_height - internode_length / 2.0) → Meshes.Rotate(RotZ(leaf_rotation)) - chnode.geometry = PlantGeom.Geometry(ref_mesh=refmesh_leaf, transformation=mesh_transformation) - # Set the second leaf in a pair opposite to the first one => add a 180° rotation - leaf_rotation += MathConstants.pi - end - end - - # Opposite decussate => 90° rotation between pairs - i += 1 - if i % 2 == 0 - leaf_rotation = MathConstants.pi / 2.0 - else - leaf_rotation = MathConstants.pi - end - - elseif symbol(node) == :Root - mesh_transformation = Meshes.Scale(root_width, root_width, root_length) → Meshes.Translate(0.0, 0.0, root_depth) → Meshes.Rotate(RotZ(MathConstants.pi)) - node.geometry = PlantGeom.Geometry(ref_mesh=refmesh_root, transformation=mesh_transformation) - root_depth -= root_length - end - end -end - -add_geometry!(mtg, refmesh_internode, refmesh_root, refmesh_leaf) - -# Visualize the mesh -using GLMakie -viz(mtg) \ No newline at end of file diff --git a/examples/ToyRUEGrowthModel.jl b/examples/ToyRUEGrowthModel.jl index c3db5cb6b..4c56b20e6 100644 --- a/examples/ToyRUEGrowthModel.jl +++ b/examples/ToyRUEGrowthModel.jl @@ -47,7 +47,4 @@ function PlantSimEngine.run!(::ToyRUEGrowthModel, models, status, meteo, constan status.biomass += status.biomass_increment end -# And optionally, we can tell PlantSimEngine that we can safely parallelize our model over space (objects): -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyRUEGrowthModel}) = PlantSimEngine.IsObjectIndependent() - -# Note that this model cannot be parallelized over time because we use the biomass from the previous time-step. \ No newline at end of file +# The model uses biomass from the previous timestep, so time steps are stateful. diff --git a/examples/ToySingleToMultiScale.jl b/examples/ToySingleToMultiScale.jl deleted file mode 100644 index e5389c60e..000000000 --- a/examples/ToySingleToMultiScale.jl +++ /dev/null @@ -1,120 +0,0 @@ -############################## -### Example single- to multi-scale conversion -############################## - -# Environment setup -using CSV -using DataFrames -using PlantSimEngine -using PlantMeteo -using PlantSimEngine.Examples -using MultiScaleTreeGraph - -# Weather data for all simulations -meteo_day = CSV.read(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), DataFrame, header=18) - -############################## -### Single-scale simulation -############################## - -models_singlescale = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2), - status=(TT_cu=cumsum(meteo_day.TT),), -) - -outputs_singlescale = run!(models_singlescale, meteo_day) - -############################## -#### Direct translation of the single-scale simulation -############################## -mapping_pseudo_multiscale = ModelMapping( - :Plant => ( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2), - Status(TT_cu=cumsum(meteo_day.TT),) - ), -) - -mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Plant, 1, 0),) - -# will generate an error as vectors can't be directly passed into a Status in multi-scale simulations -out_pseudo_multiscale = run!(mtg, mapping_pseudo_multiscale, meteo_day) - -############################## -#### Ad Hoc Cumulated Thermal Time Model -############################## - -PlantSimEngine.@process "tt_cu" verbose = false - -struct ToyTt_CuModel <: AbstractTt_CuModel -end - -function PlantSimEngine.run!(::ToyTt_CuModel, models, status, meteo, constants, extra=nothing) - status.TT_cu += - meteo.TT -end - -function PlantSimEngine.inputs_(::ToyTt_CuModel) - NamedTuple() -end - -function PlantSimEngine.outputs_(::ToyTt_CuModel) - (TT_cu=-Inf,) -end - -############################## -#### Actual multiscale version of the single-scale simulation -############################## - -mapping_multiscale = ModelMapping( - :Scene => ( - ToyTt_CuModel(), - Status(TT_cu=0.0), - ), - :Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => (:Scene => :TT_cu), - ], - ), - Beer(0.5), - ToyRUEGrowthModel(0.2), - ), -) - -# We now need two nodes for our MTG -mtg_multiscale = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) -plant = MultiScaleTreeGraph.Node(mtg_multiscale, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) -outputs_multiscale = run!(mtg_multiscale, mapping_multiscale, meteo_day) - -############################## -#### Output comparison -############################## - -computed_TT_cu_multiscale = collect(Base.Iterators.flatten(outputs_multiscale[:Scene][:TT_cu])) - -is_approx_equal_1 = true - -for i in 1:length(computed_TT_cu_multiscale) - if !(computed_TT_cu_multiscale[i] ≈ outputs_singlescale.TT_cu[i]) - is_approx_equal_1 = false - break - end -end - -is_approx_equal_1 - -is_approx_equal_2 = length(unique(computed_TT_cu_multiscale .≈ outputs_singlescale.TT_cu)) == 1 - - -# Note : it is also possible to get the weather data length via PlantSimEngine.get_nsteps(meteo_day) -# instead of checking for array length - -is_perfectly_equal = length(unique(computed_TT_cu_multiscale .== outputs_singlescale.TT_cu)) == 1 - -(computed_TT_cu_multiscale.==outputs_singlescale.TT_cu)[104] -(computed_TT_cu_multiscale.==outputs_singlescale.TT_cu)[105] diff --git a/examples/ToySoilModel.jl b/examples/ToySoilModel.jl index 7d0c20b3a..58d56193b 100644 --- a/examples/ToySoilModel.jl +++ b/examples/ToySoilModel.jl @@ -20,8 +20,9 @@ struct ToySoilWaterModel{T<:Union{AbstractRange{Float64},AbstractVector{Float64} values::T end -# Defining a method with keyword arguments and default values: -ToySoilWaterModel(values=[0.5]) = ToySoilWaterModel(values) +# Defining a zero-argument default without shadowing the generated positional +# constructor. +ToySoilWaterModel() = ToySoilWaterModel([0.5]) # Defining the inputs and outputs of the model: PlantSimEngine.inputs_(::ToySoilWaterModel) = NamedTuple() @@ -31,8 +32,3 @@ PlantSimEngine.outputs_(::ToySoilWaterModel) = (soil_water_content=-Inf,) function PlantSimEngine.run!(m::ToySoilWaterModel, models, status, meteo, constants=nothing, extra=nothing) status.soil_water_content = rand(m.values) end - -# The computation of ToySoilWaterModel is independant of previous values and other objects. We can add this information as -# traits to the model to tell PlantSimEngine that it is safe to run the models in parallel: -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToySoilWaterModel}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToySoilWaterModel}) = PlantSimEngine.IsObjectIndependent() \ No newline at end of file diff --git a/examples/benchmark.jl b/examples/benchmark.jl deleted file mode 100644 index a372ad467..000000000 --- a/examples/benchmark.jl +++ /dev/null @@ -1,31 +0,0 @@ -#]add BenchmarkTools - -using BenchmarkTools -using PlantSimEngine, PlantMeteo, DataFrames, CSV, Dates, Statistics -# using PlantSimEngine.Examples - -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Day) -models = ModelMapping( - ToyLAIModel(), - status=(TT_cu=cumsum(meteo_day.TT),), -) - -# Match the warning on the executor, the default is ThreadedEx() but ToyRUEGrowthModel can't be run in parallel: -time_run = @benchmark run!($models, $meteo_day) - -median_time_ns = median(time_run.times) / nrow(meteo_day) - -# If we provide a serial executor, it works without a warning: -time_run_seq = @benchmark run!($models, $meteo_day, executor=$(SequentialEx())) -median_time_seq_ns = median(time_run_seq.times) / nrow(meteo_day) - -# Coupled model: -models_coupled = ModelMapping( - ToyLAIModel(), - Beer(0.5), - status=(TT_cu=cumsum(meteo_day.TT),), -) - -# Match the warning on the executor, the default is ThreadedEx() but ToyRUEGrowthModel can't be run in parallel: -time_run_coupled = @benchmark run!($models_coupled, $meteo_day) -median_time_coupled_ns = median(time_run_coupled.times) / nrow(meteo_day) diff --git a/examples/dummy.jl b/examples/dummy.jl index f3a556f1d..92c3304d2 100644 --- a/examples/dummy.jl +++ b/examples/dummy.jl @@ -18,8 +18,6 @@ PlantSimEngine.outputs_(::Process1Model) = (var3=-Inf,) function PlantSimEngine.run!(::Process1Model, models, status, meteo, constants=nothing, extra=nothing) status.var3 = models.process1.a + status.var1 * status.var2 end -PlantSimEngine.TimeStepDependencyTrait(::Type{<:Process1Model}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:Process1Model}) = PlantSimEngine.IsObjectIndependent() # Defining a 2nd process called "process2", and a model @@ -34,16 +32,16 @@ A dummy model implementing a "process2" process for testing purposes. struct Process2Model <: AbstractProcess2Model end PlantSimEngine.inputs_(::Process2Model) = (var1=-Inf, var3=-Inf) PlantSimEngine.outputs_(::Process2Model) = (var4=-Inf, var5=-Inf) -PlantSimEngine.dep(::Process2Model) = (process1=AbstractProcess1Model,) +PlantSimEngine.dep(::Process2Model) = ( + process1=PlantSimEngine.Call(PlantSimEngine.One(process=:process1)), +) function PlantSimEngine.run!(::Process2Model, models, status, meteo, constants=nothing, extra=nothing) # computing var3 using process1: - PlantSimEngine.run!(models.process1, models, status, meteo, constants) + PlantSimEngine.run_call!(extra, :process1; meteo=meteo, publish=true) # computing var4 and var5: status.var4 = status.var3 * 2.0 status.var5 = status.var4 + 1.0 * meteo.T + 2.0 * meteo.Wind + 3.0 * meteo.Rh end -PlantSimEngine.TimeStepDependencyTrait(::Type{<:Process2Model}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:Process2Model}) = PlantSimEngine.IsObjectIndependent() # Defining a 3d process called "process3", and a model # that implements an algorithm, and that depends on the second one (and @@ -60,16 +58,16 @@ PlantSimEngine.inputs_(::Process3Model) = (var5=-Inf,) PlantSimEngine.outputs_(::Process3Model) = (var4=-Inf, var6=-Inf,) # NB: var4 is computed by process2, so it is not in the inputs, it is also recomputed by this model, # so we need a hard dependency on process2: -PlantSimEngine.dep(::Process3Model) = (process2=Process2Model,) +PlantSimEngine.dep(::Process3Model) = ( + process2=PlantSimEngine.Call(PlantSimEngine.One(process=:process2)), +) function PlantSimEngine.run!(::Process3Model, models, status, meteo, constants=nothing, extra=nothing) - # computing var3 using process1: - PlantSimEngine.run!(models.process2, models, status, meteo, constants, extra) + # computing var3, var4 and var5 using process2 (which calls process1): + PlantSimEngine.run_call!(extra, :process2; meteo=meteo, publish=true) # re-computing var4: status.var4 = status.var4 * 2.0 status.var6 = status.var5 + status.var4 end -PlantSimEngine.TimeStepDependencyTrait(::Type{<:Process3Model}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:Process3Model}) = PlantSimEngine.IsObjectIndependent() # Defining a 4th process called "process4", and a model # that implements an algorithm, and that computes the @@ -91,8 +89,6 @@ function PlantSimEngine.run!(::Process4Model, models, status, meteo, constants=n status.var1 = status.var0 + 0.01 status.var2 = status.var1 + 0.02 end -PlantSimEngine.TimeStepDependencyTrait(::Type{<:Process4Model}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:Process4Model}) = PlantSimEngine.IsObjectIndependent() # Defining a 5th process called "process5", and a model # that implements an algorithm, and that computes other @@ -111,8 +107,6 @@ PlantSimEngine.outputs_(::Process5Model) = (var7=-Inf,) function PlantSimEngine.run!(::Process5Model, models, status, meteo, constants=nothing, extra=nothing) status.var7 = status.var5 * status.var6 end -PlantSimEngine.TimeStepDependencyTrait(::Type{<:Process5Model}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:Process5Model}) = PlantSimEngine.IsObjectIndependent() # Defining a 6th process called "process6", and a model @@ -133,8 +127,6 @@ PlantSimEngine.outputs_(::Process6Model) = (var8=-Inf,) function PlantSimEngine.run!(::Process6Model, models, status, meteo, constants=nothing, extra=nothing) status.var8 = status.var7 + 1.0 end -PlantSimEngine.TimeStepDependencyTrait(::Type{<:Process6Model}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:Process6Model}) = PlantSimEngine.IsObjectIndependent() # Defining a 7th process called "process7", and a model # that depends on nothing but var0 so it is independant. @@ -155,5 +147,3 @@ PlantSimEngine.outputs_(::Process7Model) = (var9=-Inf,) function PlantSimEngine.run!(::Process7Model, models, status, meteo, constants=nothing, extra=nothing) status.var9 = status.var0 + 1.0 end -PlantSimEngine.TimeStepDependencyTrait(::Type{<:Process7Model}) = PlantSimEngine.IsTimeStepIndependent() -PlantSimEngine.ObjectDependencyTrait(::Type{<:Process7Model}) = PlantSimEngine.IsObjectIndependent() diff --git a/examples/maespa_model_example.jl b/examples/maespa_model_example.jl new file mode 100644 index 000000000..449c386b7 --- /dev/null +++ b/examples/maespa_model_example.jl @@ -0,0 +1,681 @@ +using Dates +using PlantMeteo +using PlantSimEngine + +include(joinpath(@__DIR__, "plantbiophysics_subsample", "Tuzet.jl")) +include(joinpath(@__DIR__, "plantbiophysics_subsample", "FvCB.jl")) +include(joinpath(@__DIR__, "plantbiophysics_subsample", "Monteith.jl")) + +PlantSimEngine.@process "soil_water" verbose = false +PlantSimEngine.@process "scene_eb" verbose = false +PlantSimEngine.@process "leaf_state" verbose = false +PlantSimEngine.@process "lai_dynamic" verbose = false +PlantSimEngine.@process "alloc_a" verbose = false +PlantSimEngine.@process "alloc_b" verbose = false + +sat_vp_kpa(T) = 0.6108 * exp(17.27 * T / (T + 237.3)) +vpd_kpa(meteo) = max(0.02, sat_vp_kpa(meteo.T) * (1.0 - meteo.Rh)) +rh_from_vpd(T, vpd) = clamp(1.0 - vpd / sat_vp_kpa(T), 0.05, 0.99) +duration_seconds(meteo) = Dates.value(Dates.Millisecond(meteo.duration)) / 1000.0 +e_sat_kpa(T) = PlantMeteo.e_sat(T) + +struct SoilWater{T} <: AbstractSoil_WaterModel + theta_sat::T + psi_e::T + b::T + depth1::T + depth2::T +end + +PlantSimEngine.inputs_(::SoilWater) = (transpiration=0.0, infiltration=0.0) +PlantSimEngine.outputs_(::SoilWater) = (theta1=0.32, theta2=0.34, psi_soil=-0.1) + +function PlantSimEngine.run!(m::SoilWater, models, status, meteo, constants, extra=nothing) + withdrawal = max(status.transpiration, 0.0) + recharge = max(status.infiltration, 0.0) + status.theta1 = clamp(status.theta1 + (recharge - 0.7 * withdrawal) / max(m.depth1 * 1000.0, 1.0), 0.04, m.theta_sat) + status.theta2 = clamp(status.theta2 - 0.3 * withdrawal / max(m.depth2 * 1000.0, 1.0), 0.04, m.theta_sat) + rel = clamp(status.theta1 / m.theta_sat, 0.05, 1.0) + status.psi_soil = m.psi_e * rel^(-m.b) + return nothing +end + +struct LeafState <: AbstractLeaf_StateModel end + +PlantSimEngine.inputs_(::LeafState) = NamedTuple() +PlantSimEngine.outputs_(::LeafState) = (leaf_area=0.0, leaf_carbon=0.0) + +PlantSimEngine.run!(::LeafState, models, status, meteo, constants, extra=nothing) = nothing + +""" + LAIModel(area) + +Compute model leaf area and leaf area index from all selected leaves. +""" +struct LAIModel{T} <: AbstractLai_DynamicModel + area::T + + function LAIModel(area::T) where {T} + area > 0 || throw(ArgumentError("`area` must be strictly positive.")) + new{T}(area) + end +end + +PlantSimEngine.inputs_(::LAIModel) = (leaf_areas=[-Inf],) +PlantSimEngine.outputs_(::LAIModel) = (lai=0.0, leaf_area=(-Inf)) + +function PlantSimEngine.run!(m::LAIModel, models, status, meteo, constants, extra=nothing) + status.leaf_area = sum(status.leaf_areas) + status.lai = status.leaf_area / m.area + return nothing +end + +struct SceneEB{I,T} <: AbstractScene_EbModel + maxiter::I + tol_t::T + tol_vpd::T + tree_height::T + zht::T + zpd::T + z0ht::T + ground_area::T + qc::T + gbcan_min::T + von_karman::T +end + +function SceneEB( + maxiter, + tol_t, + tol_vpd; + tree_height=2.0, + zht=4.0, + zpd=0.75 * tree_height, + z0ht=0.1 * tree_height, + ground_area=1.0, + qc=0.0, + gbcan_min=0.0123, + von_karman=0.41, +) + ground_area > 0.0 || throw(ArgumentError("`ground_area` must be strictly positive.")) + tree_height > 0.0 || throw(ArgumentError("`tree_height` must be strictly positive.")) + zht > 0.0 || throw(ArgumentError("`zht` must be strictly positive.")) + return SceneEB( + maxiter, + promote(tol_t, + tol_vpd, + tree_height, + zht, + zpd, + z0ht, + ground_area, + qc, + gbcan_min, + von_karman)... + ) +end + +PlantSimEngine.inputs_(::SceneEB) = ( + lai=0.0, + leaf_area=0.0, + leaf_areas=[0.0], + leaf_carbon=[0.0], + leaf_Ra_SW_f=[0.0], + leaf_aPPFD=[0.0], + Ψₗ=[0.0], + leaf_rn=[0.0], + leaf_lambda_e=[0.0], + leaf_h=[0.0], + leaf_a=[0.0], + psi_soil=-0.1, +) +PlantSimEngine.outputs_(::SceneEB) = ( + canopy_tair=20.0, + canopy_vpd=1.0, + canopy_rh=0.7, + canopy_htot=0.0, + canopy_gcanop=0.0, + scene_transpiration=0.0, + scene_infiltration=0.0, + scene_assimilation=0.0, + iterations=0, +) + +struct SceneEBSolverResult + tair::Float64 + vpd::Float64 + rh::Float64 + psi_soil::Float64 + final_meteo + iterations::Int + htot::Float64 + gcanop::Float64 + lai::Float64 +end + +function _model_leaf_meteo(meteo, tair_canopy, vpd_canopy) + return Atmosphere( + T=tair_canopy, + Rh=rh_from_vpd(tair_canopy, vpd_canopy), + Wind=meteo.Wind, + P=meteo.P, + Cₐ=meteo.Cₐ, + Ri_PAR_f=meteo.Ri_PAR_f, + Ri_SW_f=meteo.Ri_SW_f, + duration=meteo.duration, + ) +end + +function _check_leaf_vector_lengths(status, variables) + n = length(status.leaf_areas) + for variable in variables + length(getproperty(status, variable)) == n || + throw(DimensionMismatch("`$(variable)` must have the same length as `leaf_areas`.")) + end + return n +end + +function _aggregate_model_leaf_fluxes(status, ground_area, local_meteo) + n = _check_leaf_vector_lengths(status, (:leaf_rn, :leaf_lambda_e, :leaf_h, :leaf_a)) + total_rn = 0.0 + total_lambda_e = 0.0 + total_h = 0.0 + total_a = 0.0 + for i in 1:n + leaf_area = status.leaf_areas[i] + total_rn += status.leaf_rn[i] * leaf_area + total_lambda_e += status.leaf_lambda_e[i] * leaf_area + total_h += status.leaf_h[i] * leaf_area + total_a += status.leaf_a[i] * leaf_area + end + return ( + rn=total_rn / ground_area, + lambda_e=total_lambda_e / ground_area, + h=total_h / ground_area, + a=total_a / ground_area, + meteo=local_meteo, + ) +end + +function _run_model_leaf_targets!(leaf_targets, status, meteo, tair_canopy, vpd_canopy, psi_soil, ground_area; publish=false) + local_meteo = _model_leaf_meteo(meteo, tair_canopy, vpd_canopy) + # Prepare the leaf status for each leaf target, and run the energy balance for each leaf: + status.leaf_Ra_SW_f .= meteo.Ri_SW_f + status.leaf_aPPFD .= meteo.Ri_PAR_f + status.Ψₗ .= psi_soil + for target in leaf_targets + run_call!(target; meteo=local_meteo, publish=publish) + end + return _aggregate_model_leaf_fluxes(status, ground_area, local_meteo) +end + +function gbcanms(wind, zht, tree_height; gbcan_min=0.0123, von_karman=0.41) + zpd = 0.75 * tree_height + z0 = 0.1 * tree_height + zstar = max(zht, eps(Float64)) + wind2 = max(wind, 1.0e-6) + + if zstar <= tree_height + wind2 *= exp(0.13155 * (tree_height / zstar - 1.0)) + zstar = 2.0 * tree_height + end + + zstar = max(zstar, zpd + z0 + 1.0e-6) + windstar = wind2 * von_karman / log((zstar - zpd) / z0) + alpha1 = 1.5 + zw = zpd + alpha1 * (tree_height - zpd) + gbcanmsini = windstar * von_karman / log((zstar - zpd) / (zw - zpd)) + gbcanmsrou = windstar * von_karman / ((zw - tree_height) / (zw - zpd)) + canopy_air_ms = max(1.0 / (1.0 / gbcanmsini + 1.0 / gbcanmsrou), gbcan_min) + + alpha = 2.0 + z0ht2 = 0.01 + kh = alpha1 * von_karman * windstar * (tree_height - zpd) + soil_denominator = tree_height * exp(alpha) * + (exp(-alpha * z0ht2 / tree_height) - exp(-alpha * (zpd + z0) / tree_height)) + soil_canopy_ms = max(alpha * kh / soil_denominator, 0.0) + return (canopy_air_ms=canopy_air_ms, soil_canopy_ms=soil_canopy_ms) +end + +function tvpdcanopcalc(m::SceneEB, fluxes, meteo_above, canopy_meteo, constants) + gbs = gbcanms( + meteo_above.Wind, + m.zht, + m.tree_height; + gbcan_min=m.gbcan_min, + von_karman=m.von_karman, + ) + gbcan_ms = gbs.canopy_air_ms + tair_above = meteo_above.T + vpd_above = max(0.01, meteo_above.VPD) + qn = fluxes.rn + qe = fluxes.lambda_e + rad_interc = get(fluxes, :rad_interc, 0.0) + rnettot = qn + rad_interc + etot = qe + htot = rnettot - etot - m.qc + heat_conductance = constants.Cₚ * canopy_meteo.ρ * gbcan_ms + + tair_new = tair_above + htot / heat_conductance + tair_new = clamp(tair_new, tair_above - 10.0, tair_above + 10.0) + + vpair_above = e_sat_kpa(tair_above) - vpd_above + vpair_canopy = vpair_above + etot * canopy_meteo.γ / heat_conductance + vpd_new = max(0.01, e_sat_kpa(tair_new) - vpair_canopy) + vpd_new = clamp(vpd_new, max(0.01, vpd_above - 1.5), vpd_above + 1.5) + rh_new = clamp(1.0 - vpd_new / e_sat_kpa(tair_new), 0.0, 1.0) + return (tair=tair_new, vpd=vpd_new, rh=rh_new, htot=htot, gcanop=gbcan_ms) +end + +function _solve_model_energy_balance!( + m::SceneEB, + leaf_targets, + status, + meteo, + constants=PlantMeteo.Constants(), +) + tair_above = meteo.T + vpd_above = max(0.01, meteo.VPD) + tair_canopy = tair_above + vpd_canopy = vpd_above + psi_soil = status.psi_soil + final_meteo = meteo + last_update = (tair=tair_canopy, vpd=vpd_canopy, rh=meteo.Rh, htot=0.0, gcanop=0.0) + + for iter in 1:m.maxiter + # Run the energy balance of each leaf, and aggregate the fluxes at the canopy scale: + fluxes = _run_model_leaf_targets!(leaf_targets, status, meteo, tair_canopy, vpd_canopy, psi_soil, m.ground_area) + fluxes = merge(fluxes, (lai=status.lai, rad_interc=0.0)) + # Update the canopy-scale meteo based on the leaf fluxes, and check for convergence: + final_meteo = fluxes.meteo + update = tvpdcanopcalc(m, fluxes, meteo, final_meteo, constants) + last_update = update + if abs(update.tair - tair_canopy) < m.tol_t && abs(update.vpd - vpd_canopy) < m.tol_vpd + tair_canopy = update.tair + vpd_canopy = update.vpd + return SceneEBSolverResult( + tair_canopy, + vpd_canopy, + update.rh, + psi_soil, + _model_leaf_meteo(meteo, tair_canopy, vpd_canopy), + iter, + update.htot, + update.gcanop, + status.lai, + ) + end + tair_canopy = 0.5 * (tair_canopy + update.tair) # take the average to help convergence + vpd_canopy = 0.5 * (vpd_canopy + update.vpd) + end + + error( + "SceneEB did not converge after $(m.maxiter) iterations ", + "(tol_t=$(m.tol_t), tol_vpd=$(m.tol_vpd), ", + "last_tair=$(last_update.tair), last_vpd=$(last_update.vpd))." + ) +end + +function _publish_model_leaf_solution!(leaf_targets, status, solution::SceneEBSolverResult, meteo, ground_area) + fluxes = _run_model_leaf_targets!( + leaf_targets, + status, + meteo, + solution.tair, + solution.vpd, + solution.psi_soil, + ground_area; + publish=true, + ) + n = _check_leaf_vector_lengths(status, (:leaf_carbon, :leaf_a)) + for i in 1:n + status.leaf_carbon[i] += status.leaf_a[i] * status.leaf_areas[i] * duration_seconds(meteo) * 12.0e-6 + end + return fluxes +end + +function PlantSimEngine.run!(m::SceneEB, models, status, meteo, constants, extra) + leaf_targets = call_targets(extra, :energy_balance) + solution = _solve_model_energy_balance!(m, leaf_targets, status, meteo, constants) + fluxes = _publish_model_leaf_solution!(leaf_targets, status, solution, meteo, m.ground_area) + transpiration_mm = λE_to_E(fluxes.lambda_e, solution.final_meteo.λ) * duration_seconds(meteo) * 18.0e-6 + + status.canopy_tair = solution.tair + status.canopy_vpd = solution.vpd + status.canopy_rh = solution.rh + status.canopy_htot = solution.htot + status.canopy_gcanop = solution.gcanop + status.scene_transpiration = transpiration_mm + status.scene_infiltration = 0.0 + status.scene_assimilation = fluxes.a + status.iterations = solution.iterations + run_call!(extra, :soil; publish=true) + return nothing +end + +alloc_inputs() = (leaf_carbon=[0.0],) +alloc_outputs() = (daily_growth=0.0, leaf_pool=0.0, wood_pool=0.0) + +function allocate!(status, leaf_fraction, wood_fraction) + carbon = sum(status.leaf_carbon) + status.daily_growth = carbon + status.leaf_pool += leaf_fraction * carbon + status.wood_pool += wood_fraction * carbon + return nothing +end + +struct AllocA <: AbstractAlloc_AModel + leaf_fraction::Float64 + wood_fraction::Float64 +end + +struct AllocB <: AbstractAlloc_BModel + leaf_fraction::Float64 + wood_fraction::Float64 +end + +PlantSimEngine.inputs_(::AllocA) = alloc_inputs() +PlantSimEngine.outputs_(::AllocA) = alloc_outputs() +PlantSimEngine.inputs_(::AllocB) = alloc_inputs() +PlantSimEngine.outputs_(::AllocB) = alloc_outputs() + +PlantSimEngine.run!(m::AllocA, models, status, meteo, constants, extra=nothing) = + allocate!(status, m.leaf_fraction, m.wood_fraction) +PlantSimEngine.run!(m::AllocB, models, status, meteo, constants, extra=nothing) = + allocate!(status, m.leaf_fraction, m.wood_fraction) + +function _maespa_leaf_status(; leaf_area, sky_fraction, d) + return Status( + Ra_SW_f=0.0, + sky_fraction=sky_fraction, + d=d, + aPPFD=0.0, + Ψₗ=-0.1, + leaf_area=leaf_area, + leaf_carbon=0.0, + Tₗ=20.0, + Rn=0.0, + Ra_LW_f=0.0, + H=0.0, + λE=0.0, + Cₛ=400.0, + Cᵢ=300.0, + A=0.0, + Gₛ=0.0, + Gbₕ=0.0, + Dₗ=0.0, + Gbc=0.0, + iter=0, + ) +end + +_maespa_plant_status() = Status(leaf_carbon=[0.0], daily_growth=0.0, leaf_pool=0.0, wood_pool=0.0) + +function _maespa_model_status() + return Status( + leaf_areas=[0.0], + leaf_carbon=[0.0], + leaf_Ra_SW_f=[0.0], + leaf_aPPFD=[0.0], + Ψₗ=[-0.1], + leaf_rn=[0.0], + leaf_lambda_e=[0.0], + leaf_h=[0.0], + leaf_a=[0.0], + leaf_area=0.0, + lai=0.0, + canopy_tair=20.0, + canopy_vpd=1.0, + canopy_rh=0.7, + canopy_htot=0.0, + canopy_gcanop=0.0, + scene_transpiration=0.0, + scene_infiltration=0.0, + scene_assimilation=0.0, + psi_soil=-0.1, + iterations=0, + ) +end + +function _maespa_soil_status() + return Status(theta1=0.33, theta2=0.36, psi_soil=-0.10, transpiration=0.0, infiltration=0.0) +end + +function _maespa_species_template(species; monteith, fvcb, tuzet, allocation) + return CompositeModelTemplate( + ( + ModelSpec(monteith; name=:energy_balance) |> + AppliesTo(Many(scale=:Leaf)) |> + Calls(:photosynthesis => One(scale=:Leaf, application=:photosynthesis)) |> + TimeStep(Dates.Hour(1)), + ModelSpec(fvcb; name=:photosynthesis) |> + AppliesTo(Many(scale=:Leaf)) |> + Calls(:stomatal_conductance => One(scale=:Leaf, application=:stomatal_conductance)) |> + TimeStep(Dates.Hour(1)), + ModelSpec(tuzet; name=:stomatal_conductance) |> + AppliesTo(Many(scale=:Leaf)) |> + TimeStep(Dates.Hour(1)), + ModelSpec(LeafState(); name=:leaf_state) |> + AppliesTo(Many(scale=:Leaf)) |> + TimeStep(Dates.Hour(1)), + ModelSpec(allocation; name=:allocation) |> + AppliesTo(One(scale=:Plant)) |> + Inputs(:leaf_carbon => Many(scale=:Leaf, within=Subtree(), var=:leaf_carbon)) |> + TimeStep(Dates.Day(1)), + ); + kind=:plant, + species=species, + ) +end + +function _maespa_plant_instance(name, template; nleaves, leaf_area, sky_fraction, d) + plant_id = Symbol(name) + axis_id = Symbol(name, "_axis") + leaves = ntuple(nleaves) do index + Object( + Symbol(name, "_leaf_", index); + scale=:Leaf, + parent=axis_id, + status=_maespa_leaf_status(; leaf_area=leaf_area, sky_fraction=sky_fraction, d=d), + ) + end + return ObjectInstance( + name, + template; + root=Object(plant_id; scale=:Plant, parent=:model, status=_maespa_plant_status()), + objects=( + Object(Symbol(name, "_axis"); scale=:Internode, parent=plant_id), + leaves..., + ), + ) +end + +function build_maespa_model(; scene_model=SceneEB(25, 0.03, 0.005), meteo=maespa_meteo()) + template_a = _maespa_species_template( + :A; + monteith=Monteith(; ε=0.955, maxiter=20, ΔT=0.02), + fvcb=Fvcb(; VcMaxRef=72.0, JMaxRef=135.0, RdRef=1.1), + tuzet=Tuzet(; g0=0.015, g1=4.8, Ψᵥ=-1.4, sf=3.2, Γ=42.0), + allocation=AllocA(0.35, 0.55), + ) + template_b = _maespa_species_template( + :B; + monteith=Monteith(; ε=0.955, maxiter=20, ΔT=0.02), + fvcb=Fvcb(; VcMaxRef=58.0, JMaxRef=110.0, RdRef=1.3), + tuzet=Tuzet(; g0=0.012, g1=3.5, Ψᵥ=-1.1, sf=3.8, Γ=42.0), + allocation=AllocB(0.55, 0.35), + ) + ground_area = scene_model.ground_area + return CompositeModel( + Object(:model; scale=:Scene, kind=:model, status=_maespa_model_status()), + Object(:soil; scale=:Soil, kind=:soil, parent=:model, status=_maespa_soil_status()), + _maespa_plant_instance( + :plant_A, + template_a; + nleaves=2, + leaf_area=0.018, + sky_fraction=1.0, + d=0.035, + ), + _maespa_plant_instance( + :plant_B, + template_b; + nleaves=3, + leaf_area=0.014, + sky_fraction=0.8, + d=0.028, + ); + applications=( + ModelSpec(LAIModel(ground_area); name=:lai_dynamic) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :leaf_areas => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + process=:leaf_state, + var=:leaf_area, + ), + ) |> + TimeStep(Dates.Day(1)), + ModelSpec(scene_model; name=:scene_eb) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :leaf_areas => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + process=:leaf_state, + var=:leaf_area, + ), + :leaf_carbon => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + process=:leaf_state, + var=:leaf_carbon, + ), + :leaf_Ra_SW_f => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + var=:Ra_SW_f, + ), + :leaf_aPPFD => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + var=:aPPFD, + ), + :Ψₗ => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + var=:Ψₗ, + ), + :leaf_rn => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + policy=HoldLast(), + var=:Rn, + ), + :leaf_lambda_e => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + policy=HoldLast(), + var=:λE, + ), + :leaf_h => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + policy=HoldLast(), + var=:H, + ), + :leaf_a => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + policy=HoldLast(), + var=:A, + ), + :psi_soil => One( + kind=:soil, + scale=:Soil, + application=:soil_water, + var=:psi_soil, + ), + ) |> + Calls( + :energy_balance => Many(kind=:plant, scale=:Leaf, process=:energy_balance), + :soil => One(kind=:soil, scale=:Soil, application=:soil_water), + ) |> + TimeStep(Dates.Hour(1)), + ModelSpec(SoilWater(0.45, -0.03, 4.4, 0.25, 0.75); name=:soil_water) |> + AppliesTo(One(kind=:soil, scale=:Soil)) |> + Inputs( + :transpiration => One( + scale=:Scene, + within=SceneScope(), + application=:scene_eb, + var=:scene_transpiration, + ), + :infiltration => One( + scale=:Scene, + within=SceneScope(), + application=:scene_eb, + var=:scene_infiltration, + ), + ) |> + TimeStep(Dates.Hour(1)), + ), + environment=meteo, + ) +end + +function maespa_meteo(; nhours=24) + return Weather([ + Atmosphere( + T=22.0 + 5.0 * sinpi((hour - 7) / 12), + Rh=clamp(0.72 - 0.22 * sinpi((hour - 7) / 12), 0.35, 0.90), + Wind=1.2 + 0.3 * sinpi(hour / 12), + Ri_PAR_f=max(0.0, 900.0 * sinpi((hour - 6) / 12)), + Ri_SW_f=max(0.0, 450.0 * sinpi((hour - 6) / 12)), + duration=Dates.Hour(1), + ) + for hour in 1:nhours + ]) +end + +function run_maespa_example(; nhours=24, check=true) + model = build_maespa_model(; meteo=maespa_meteo(; nhours=nhours)) + compiled = Advanced.compile_composite_model(model) + check && Advanced.refresh_environment_bindings!(model, compiled) + simulation = run!( + model; + steps=nhours, + constants=PlantMeteo.Constants(), + outputs=:all, + ) + return ( + model=model, + compiled=simulation.compiled, + environment=simulation.environment_bindings, + simulation=simulation, + ) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + result = run_maespa_example() + model = result.model + println("leaf_count = ", length(model_objects(model; scale=:Leaf))) + println( + "scene_transpiration = ", + only(model_objects(model; scale=:Scene)).status.scene_transpiration, + ) + println("psi_soil = ", only(model_objects(model; kind=:soil)).status.psi_soil) + println("plant_A = ", only(model_objects(model; name=:plant_A)).status.daily_growth) + println("plant_B = ", only(model_objects(model; name=:plant_B)).status.daily_growth) +end diff --git a/examples/plantbiophysics_subsample/FvCB.jl b/examples/plantbiophysics_subsample/FvCB.jl new file mode 100644 index 000000000..de6b83836 --- /dev/null +++ b/examples/plantbiophysics_subsample/FvCB.jl @@ -0,0 +1,189 @@ +# Generate all methods for the photosynthesis process: several meteo time-steps, components, +# over an MTG, and the mutating /non-mutating versions +@process "photosynthesis" verbose = false + +# Default policy for assimilation rates when consumed at coarser clocks. +# An explicit model `Inputs(...)` policy overrides this default. +PlantSimEngine.output_policy(::Type{<:AbstractPhotosynthesisModel}) = (A=PlantSimEngine.Integrate(PlantMeteo.DurationSumReducer()),) + + +""" +Farquhar–von Caemmerer–Berry (FvCB) model for C3 photosynthesis (Farquhar et al., 1980; +von Caemmerer and Farquhar, 1981) coupled with a conductance model. +""" +struct Fvcb{T} <: AbstractPhotosynthesisModel + Tᵣ::T + VcMaxRef::T + JMaxRef::T + RdRef::T + TPURef::T + Eₐᵣ::T + O₂::T + Eₐⱼ::T + Hdⱼ::T + Δₛⱼ::T + Eₐᵥ::T + Hdᵥ::T + Δₛᵥ::T + α::T + θ::T +end + +function Fvcb(; Tᵣ=25.0, VcMaxRef=200.0, JMaxRef=250.0, RdRef=0.6, TPURef=9999.0, Eₐᵣ=46390.0, + O₂=210.0, Eₐⱼ=29680.0, Hdⱼ=200000.0, Δₛⱼ=631.88, Eₐᵥ=58550.0, Hdᵥ=200000.0, + Δₛᵥ=629.26, α=0.425, θ=0.7) + + Fvcb(promote(Tᵣ, VcMaxRef, JMaxRef, RdRef, TPURef, Eₐᵣ, O₂, Eₐⱼ, Hdⱼ, Δₛⱼ, Eₐᵥ, Hdᵥ, Δₛᵥ, α, θ)...) +end + +function PlantSimEngine.inputs_(::Fvcb) + (aPPFD=-Inf, Tₗ=-Inf, Cₛ=-Inf) +end + +function PlantSimEngine.outputs_(::Fvcb) + (A=-Inf, Gₛ=-Inf, Cᵢ=-Inf) +end + +Base.eltype(x::Fvcb) = typeof(x).parameters[1] + +PlantSimEngine.dep(::Fvcb) = ( + stomatal_conductance=PlantSimEngine.Call( + PlantSimEngine.One(scale=:Leaf, process=:stomatal_conductance), + ), +) +PlantSimEngine.timestep_hint(::Type{<:Fvcb}) = ( + required=(Dates.Minute(1), Dates.Hour(6)), + preferred=Dates.Hour(1) +) + +PlantSimEngine.output_policy(::Type{<:Fvcb}) = ( + A=PlantSimEngine.Integrate(PlantMeteo.DurationSumReducer()), # from μmol m-2 s-1 to μmol m-2 timerstep-1 + Cᵢ=PlantSimEngine.Integrate(PlantMeteo.MeanReducer()), + Gₛ=PlantSimEngine.Integrate(PlantMeteo.DurationSumReducer()), +) + +function arrhenius(kref, Eₐ, Tₖ, Tᵣₖ, R) + kref * exp(Eₐ * (Tₖ - Tᵣₖ) / (R * Tₖ * Tᵣₖ)) +end + +function arrhenius(kref, Eₐ, Tₖ, Tᵣₖ, Hd, Δₛ, R) + activation = arrhenius(kref, Eₐ, Tₖ, Tᵣₖ, R) + deactivation_ref = 1.0 + exp((Tᵣₖ * Δₛ - Hd) / (R * Tᵣₖ)) + deactivation = 1.0 + exp((Tₖ * Δₛ - Hd) / (R * Tₖ)) + return activation * deactivation_ref / deactivation +end + +function Γ_star(Tₖ, Tᵣₖ, R=PlantMeteo.Constants().R) + arrhenius(oftype(Tₖ, 42.75), oftype(Tₖ, 37830.0), Tₖ, Tᵣₖ, R) +end + +function get_km(Tₖ, Tᵣₖ, O₂, R=PlantMeteo.Constants().R) + KC = arrhenius(oftype(Tₖ, 404.9), oftype(Tₖ, 79430.0), Tₖ, Tᵣₖ, R) + KO = arrhenius(oftype(Tₖ, 278.4), oftype(Tₖ, 36380.0), Tₖ, Tᵣₖ, R) + return KC * (1.0 + O₂ / KO) +end + +function PlantSimEngine.run!(m::Fvcb, models, status, meteo, constants=PlantMeteo.Constants(), extra=nothing) + + # Tranform Celsius temperatures in Kelvin: + Tₖ = status.Tₗ - constants.K₀ + Tᵣₖ = m.Tᵣ - constants.K₀ + + # Temperature dependence of the parameters: + Γˢ = Γ_star(Tₖ, Tᵣₖ, constants.R) # Gamma star (CO2 compensation point) in μmol mol-1 + Km = get_km(Tₖ, Tᵣₖ, m.O₂, constants.R) # effective Michaelis–Menten coefficient for CO2 + + # Maximum electron transport rate at the given leaf temperature (μmol m-2 s-1): + JMax = arrhenius(m.JMaxRef, m.Eₐⱼ, Tₖ, Tᵣₖ, m.Hdⱼ, m.Δₛⱼ, constants.R) + # Maximum rate of Rubisco activity at the given models temperature (μmol m-2 s-1): + VcMax = arrhenius(m.VcMaxRef, m.Eₐᵥ, Tₖ, Tᵣₖ, m.Hdᵥ, m.Δₛᵥ, constants.R) + # Rate of mitochondrial respiration at the given leaf temperature (μmol m-2 s-1): + Rd = arrhenius(m.RdRef, m.Eₐᵣ, Tₖ, Tᵣₖ, constants.R) + # Rd is also described as the CO2 release in the light by processes other than the PCO + # cycle, and termed "day" respiration, or "light respiration" (Harley et al., 1986). + + # Actual electron transport rate (considering intercepted PAR and leaf temperature): + J = get_J(status.aPPFD, JMax, m.α, m.θ) # in μmol m-2 s-1 + # RuBP regeneration + Vⱼ = J / 4 + + # Stomatal conductance (mol[CO₂] m-2 s-1), dispatched on type of first argument (gs_closure): + st_closure = gs_closure(models.stomatal_conductance, models, status, meteo, extra) + + Cᵢⱼ = get_Cᵢⱼ(Vⱼ, Γˢ, status.Cₛ, Rd, models.stomatal_conductance.g0, st_closure) + + # Electron-transport-limited rate of CO2 assimilation (RuBP regeneration-limited): + Wⱼ = Vⱼ * (Cᵢⱼ - Γˢ) / (Cᵢⱼ + 2.0 * Γˢ) # also called Aⱼ + # See Von Caemmerer, Susanna. 2000. Biochemical models of leaf photosynthesis. + # Csiro publishing, eq. 2.23. + # NB: here the equation is modified because we use Vⱼ instead of J, but it is the same. + + # If Rd is larger than Wⱼ, no assimilation: + if Wⱼ - Rd < 1.0e-6 + Cᵢⱼ = Γˢ + Wⱼ = Vⱼ * (Cᵢⱼ - Γˢ) / (Cᵢⱼ + 2.0 * Γˢ) + end + + Cᵢᵥ = get_Cᵢᵥ(VcMax, Γˢ, status.Cₛ, Rd, models.stomatal_conductance.g0, st_closure, Km) + + # Rubisco-carboxylation-limited rate of CO₂ assimilation (RuBP activity-limited): + if Cᵢᵥ <= 0.0 || Cᵢᵥ > status.Cₛ + Wᵥ = 0.0 + else + Wᵥ = VcMax * (Cᵢᵥ - Γˢ) / (Cᵢᵥ + Km) + end + + # Net assimilation (μmol m-2 s-1) + status.A = min(Wᵥ, Wⱼ, 3 * m.TPURef) - Rd + + # Stomatal conductance (mol[CO₂] m-2 s-1) + PlantSimEngine.run_call!( + extra, + :stomatal_conductance; + meteo=st_closure, + publish=false, + ) + + # Intercellular CO₂ concentration (Cᵢ, μmol mol) + status.Cᵢ = min(status.Cₛ, status.Cₛ - status.A / status.Gₛ) + nothing +end + +function get_J(aPPFD, JMax, α, θ) + (α * aPPFD + JMax - sqrt((α * aPPFD + JMax)^2 - 4 * α * θ * aPPFD * JMax)) / (2 * θ) +end + +function get_Cᵢⱼ(Vⱼ, Γˢ, Cₛ, Rd, g0, st_closure) + a = g0 + st_closure * (Vⱼ - Rd) + b = (1.0 - Cₛ * st_closure) * (Vⱼ - Rd) + g0 * (2.0 * Γˢ - Cₛ) - + st_closure * (Vⱼ * Γˢ + 2.0 * Γˢ * Rd) + c = -(1.0 - Cₛ * st_closure) * Γˢ * (Vⱼ + 2.0 * Rd) - + g0 * 2.0 * Γˢ * Cₛ + + return positive_root(a, b, c) +end + +function get_Cᵢᵥ(VcMAX, Γˢ, Cₛ, Rd, g0, st_closure, Km) + a = g0 + st_closure * (VcMAX - Rd) + b = (1.0 - Cₛ * st_closure) * (VcMAX - Rd) + g0 * (Km - Cₛ) - st_closure * (VcMAX * Γˢ + Km * Rd) + c = -(1.0 - Cₛ * st_closure) * (VcMAX * Γˢ + Km * Rd) - g0 * Km * Cₛ + + return positive_root(a, b, c) +end + +function max_root(a, b, c) + Δ = b^2.0 - 4.0 * a * c + x1 = (-b + sqrt(Δ)) / (2.0 * a) + x2 = (-b - sqrt(Δ)) / (2.0 * a) + return max(x1, x2) +end + +function positive_root(a, b, c) + Δ = b^2.0 - 4.0 * a * c + return Δ >= 0.0 ? (-b + sqrt(Δ)) / (2.0 * a) : 0.0 +end + +function negative_root(a, b, c) + Δ = b^2.0 - 4.0 * a * c + return Δ >= 0.0 ? (-b - sqrt(Δ)) / (2.0 * a) : 0.0 +end diff --git a/examples/plantbiophysics_subsample/Monteith.jl b/examples/plantbiophysics_subsample/Monteith.jl new file mode 100644 index 000000000..324b24e97 --- /dev/null +++ b/examples/plantbiophysics_subsample/Monteith.jl @@ -0,0 +1,711 @@ +#! Careful: this file is a copy/paste from the original model implementation in PlantBiophysics.jl (v0.16.2). It is only used for testing. +#! If you want to use this model, use the one from PlantBiophysics.jl instead, which is more up to date and maintained. + +@process "energy_balance" verbose = false +""" + black_body(T, K₀, σ) + black_body(T) + +Thermal infrared, *i.e.* longwave radiation emitted from a black body at temperature T. + +- `T`: temperature of the object in Celsius degree +- `K₀`: absolute zero (°C) +- `σ` (``W\\ m^{-2}\\ K^{-4}``) [Stefan-Boltzmann constant](https://en.wikipedia.org/wiki/Stefan%E2%80%93Boltzmann_law) + +# Note + +`K₀` and `σ` are taken from `PlantMeteo.Constants` if not provided. + +""" +function black_body(T, K₀, σ) + Tₖ = T - K₀ + σ * (Tₖ^4.0) +end + +function black_body(T) + constants = PlantMeteo.Constants() + black_body(T, constants.K₀, constants.σ) +end + + +""" +Thermal infrared, *i.e.* longwave radiation emitted from an object at temperature T. + +- `T`: temperature of the object in Celsius degree +- `ε` object [emissivity](https://en.wikipedia.org/wiki/Emissivity) (not to confuse with ε the +ratio of molecular weights from `PlantMeteo.Constants`). A typical value for a leaf is 0.955. +- `K₀`: absolute zero (°C) +- `σ` (``W\\ m^{-2}\\ K^{-4}``) [Stefan-Boltzmann constant](https://en.wikipedia.org/wiki/Stefan%E2%80%93Boltzmann_law) + +# Note + +`K₀` and `σ` are taken from `PlantMeteo.Constants` if not provided. + +# Examples + +```julia +# Thermal infrared radiation of water at 25 °C: +grey_body(25.0, 0.96) +``` +""" +function grey_body(T, ε, K₀, σ) + ε * black_body(T, K₀, σ) +end + +function grey_body(T, ε) + constants = PlantMeteo.Constants() + grey_body(T, ε, constants.K₀, constants.σ) +end + + +""" + net_longwave_radiation(T₁,T₂,ε₁,ε₂,F₁,K₀,σ) + net_longwave_radiation(T₁,T₂,ε₁,ε₂,F₁) + +Net longwave radiation fluxes (*i.e.* thermal radiation, W m-2) between an object and another. +The object of interest is at temperature T₁ and has an emissivity ε₁, and the object with +which it exchanges energy is at temperature T₂ and has an emissivity ε₂. + +If the result is positive, then the object of interest gain energy. + +# Arguments + +- `T₁` (Celsius degree): temperature of the target object (object 1) +- `T₂` (Celsius degree): temperature of the object with which there is potential exchange (object 2) +- `ε₁`: object 1 emissivity +- `ε₂`: object 2 emissivity +- `F₁`: view factor (0-1), *i.e.* visible fraction of object 2 from object 1 (see note) +- `K₀`: absolute zero (°C) +- `σ` (``W\\ m^{-2}\\ K^{-4}``) [Stefan-Boltzmann constant](https://en.wikipedia.org/wiki/Stefan%E2%80%93Boltzmann_law) + +# Note + +`F₁`, the view factor (also called shape factor) is a coefficient applied to the semi-hemisphere +field of view of object 1 that "sees" object 2. E.g. a leaf can be viewed as a plane. If one side +of the leaf sees only object 2 in its field of view (e.g. the sky), then `F₁ = 1`. +Then the net longwave radiation flux for this part of the leaf is multiplied by its actual +surface to get the exchange. Note that we apply reciprocity between the two objects for +the view factor (they have the same value), *i.e.*: A₁F₁₂ = A₂F₂₁. + +Then, if we take a leaf as object 1, and the sky as object 2, the visible fraction of +sky viewed by the leaf would be: + +- `0.5` if the leaf is on top of the canopy, *i.e.* the upper side of the leaf sees the sky, +the side bellow sees other leaves and the soil. +- between 0 and 0.5 if it is within the canopy and partly shaded by other objects. + +Note that `A₁` for a leaf is twice its common used leaf area, because `A₁` is the **total** +leaf area of the object that exchange energy. + +```julia +# Net thermal radiation fluxes between a leaf and the sky considering the leaf at the top of +# the canopy: +Tₗ = 25.0 ; Tₐ = 20.0 +ε₁ = 0.955 ; ε₂ = 1.0 +Ra_LW_f = net_longwave_radiation(Tₗ,Tₐ,ε₁,ε₂,1.0) +Ra_LW_f + +# Ra_LW_f is the net longwave radiation flux between the leaf and the atmosphere per surface area. +# To get the actual net longwave radiation flux we need to multiply by the surface of the +# leaf, e.g. for a leaf of 2cm²: +leaf_area = 2e-4 # in m² +Ra_LW_f * leaf_area + +# The leaf lose ~0.0055 W towards the atmosphere. +``` + +# References + +Cengel, Y, et Transfer Mass Heat. 2003. A practical approach. New York, NY, USA: McGraw-Hill. +""" +function net_longwave_radiation(T₁, T₂, ε₁, ε₂, F₁, K₀, σ) + (black_body(T₂, K₀, σ) - black_body(T₁, K₀, σ)) / (1.0 / ε₁ + 1.0 / ε₂ - 1.0) * F₁ +end + +function net_longwave_radiation(T₁, T₂, ε₁, ε₂, F₁) + constants = PlantMeteo.Constants() + net_longwave_radiation(T₁, T₂, ε₁, ε₂, F₁, constants.K₀, constants.σ) +end + +""" + gbₕ_free(Tₐ,Tₗ,d,Dₕ₀) + gbₕ_free(Tₐ,Tₗ,d) + +Leaf boundary layer conductance for heat under **free** convection (m s-1). + +# Arguments + +- `Tₐ` (°C): air temperature +- `Tₗ` (°C): leaf temperature +- `d` (m): characteristic dimension, *e.g.* leaf width (see eq. 10.9 from Monteith and Unsworth, 2013). +- `Dₕ₀ = 21.5e-6`: molecular diffusivity for heat at base temperature. Use value from +`PlantMeteo.Constants` if not provided. + +# Note + +`R` and `Dₕ₀` can be found using `PlantMeteo.Constants`. To transform in ``mol\\ m^{-2}\\ s^{-1}``, +use [`ms_to_mol`](@ref). + +# References + +Leuning, R., F. M. Kelliher, DGG de Pury, et E.-D. SCHULZE. 1995. « Leaf nitrogen, +photosynthesis, conductance and transpiration: scaling from leaves to canopies ». Plant, +Cell & Environment 18 (10): 1183‑1200. + +Monteith, John, et Mike Unsworth. 2013. Principles of environmental physics: plants, +animals, and the atmosphere. Academic Press. Paragraph 10.1.3, eq. 10.9. +""" +function gbₕ_free(Tₐ, Tₗ, d, Dₕ₀=PlantMeteo.Constants().Dₕ₀) + zeroT = zero(Tₐ) # make it type stable + + if abs(Tₗ - Tₐ) > zeroT + Gr = 1.58e8 * d^3.0 * abs(Tₗ - Tₐ) # Grashof number (Monteith and Unsworth, 2013) + # !Note: Leuning et al. (1995) use 1.6e8 (eq. E4). + # Leuning et al. (1995) eq. E3: + Gbₕ_free = 0.5 * get_Dₕ(Tₐ, Dₕ₀) * (Gr^0.25) / d + else + Gbₕ_free = zeroT + end + + return Gbₕ_free +end + + +""" + gbₕ_forced(Wind,d) + +Boundary layer conductance for heat under **forced** convection (m s-1). See eq. E1 from +Leuning et al. (1995) for more details. + +# Arguments + +- `Wind` (m s-1): wind speed +- `d` (m): characteristic dimension, *e.g.* leaf width (see eq. 10.9 from Monteith and Unsworth, 2013). + +# Notes + +`d` is the minimal dimension of the surface of an object in contact with the air. + +# References + +Leuning, R., F. M. Kelliher, DGG de Pury, et E.-D. SCHULZE. 1995. « Leaf nitrogen, +photosynthesis, conductance and transpiration: scaling from leaves to canopies ». Plant, +Cell & Environment 18 (10): 1183‑1200. +""" +function gbₕ_forced(Wind, d) + 0.003 * sqrt(Wind / d) +end + + +""" + get_Dₕ(T,Dₕ₀) + get_Dₕ(T) + +Dₕ -molecular diffusivity for heat at base temperature- from Dₕ₀ (corrected by temperature). +See Monteith and Unsworth (2013, eq. 3.10). + +# Arguments + +- `Tₐ` (°C): temperature +- `Dₕ₀`: molecular diffusivity for heat at base temperature. Use value from `PlantMeteo.Constants` +if not provided. + +# References + +Monteith, John, et Mike Unsworth. 2013. Principles of environmental physics: plants, +animals, and the atmosphere. Academic Press. Paragraph 10.1.3. +""" +function get_Dₕ(T, Dₕ₀=PlantMeteo.Constants().Dₕ₀) + Dₕ₀ * (1 + 0.007 * T) +end + +""" + ms_to_mol(G,T,P,R,K₀) + ms_to_mol(G,T,P) + +Conversion of a conductance `G` from ``m\\ s^{-1}`` to ``mol\\ m^{-2}\\ s^{-1}``. + +# Arguments + +- `G` (``m\\ s^{-1}``): conductance +- `T` (°C): air temperature +- `P` (kPa): air pressure +- `R` (``J\\ mol^{-1}\\ K^{-1}``): universal gas constant. +- `K₀` (°C): absolute zero + +# See also + +[`mol_to_ms`](@ref) for the inverse process. +""" +function ms_to_mol(G, T, P, R, K₀) + G * f_ms_to_mol(T, P, R, K₀) +end + +function ms_to_mol(G, T, P) + constants = PlantMeteo.Constants() + ms_to_mol(G, T, P, constants.R, constants.K₀) +end + +""" + ms_to_mol(G,T,P,R,K₀) + ms_to_mol(G,T,P) + +Conversion of a conductance `G` from ``mol\\ m^{-2}\\ s^{-1}`` to ``m\\ s^{-1}``. + +# Arguments + +- `G` (``m\\ s^{-1}``): conductance +- `T` (°C): air temperature +- `P` (kPa): air pressure +- `R` (``J\\ mol^{-1}\\ K^{-1}``): universal gas constant. +- `K₀` (°C): absolute zero + +# See also + +[`ms_to_mol`](@ref) for the inverse process. +""" +function mol_to_ms(G, T, P, R, K₀) + G / f_ms_to_mol(T, P, R, K₀) +end + +function mol_to_ms(G, T, P) + constants = PlantMeteo.Constants() + mol_to_ms(G, T, P, constants.R, constants.K₀) +end + +""" +Conversion factor between conductance in ``m\\ s^{-1}`` to ``mol\\ m^{-2}\\ s^{-1}``. + +# Arguments + +- `T` (°C): air temperature +- `P` (kPa): air pressure +- `R` (``J\\ mol^{-1}\\ K^{-1}``): universal gas constant. +- `K₀` (°C): absolute zero +""" +function f_ms_to_mol(T, P, R, K₀) + (P * 1000) / (R * (T - K₀)) +end + +""" + gbh_to_gbw(gbh, Gbₕ_to_Gbₕ₂ₒ = PlantMeteo.Constants().Gbₕ_to_Gbₕ₂ₒ) + gbw_to_gbh(gbh, Gbₕ_to_Gbₕ₂ₒ = PlantMeteo.Constants().Gbₕ_to_Gbₕ₂ₒ) + +Boundary layer conductance for water vapor from boundary layer conductance for heat. + +# Arguments + +- `gbh` (m s-1): boundary layer conductance for heat under mixed convection. +- `Gbₕ_to_Gbₕ₂ₒ`: conversion factor. + +# Note + +Gbₕ is the sum of free and forced convection. See [`gbₕ_free`](@ref) and [`gbₕ_forced`](@ref). +""" +function gbh_to_gbw(gbh, Gbₕ_to_Gbₕ₂ₒ=PlantMeteo.Constants().Gbₕ_to_Gbₕ₂ₒ) + gbh * Gbₕ_to_Gbₕ₂ₒ +end + +function gbw_to_gbh(gbh, Gbₕ_to_Gbₕ₂ₒ=PlantMeteo.Constants().Gbₕ_to_Gbₕ₂ₒ) + gbh / Gbₕ_to_Gbₕ₂ₒ +end + + +""" + gsc_to_gsw(Gₛ, Gsc_to_Gsw = PlantMeteo.Constants().Gsc_to_Gsw) + +Conversion of a stomatal conductance for CO₂ into stomatal conductance for H₂O. +""" +function gsc_to_gsw(Gₛ, Gsc_to_Gsw=PlantMeteo.Constants().Gsc_to_Gsw) + Gₛ * Gsc_to_Gsw +end + +""" + gsw_to_gsc(Gₛ, Gsc_to_Gsw = PlantMeteo.Constants().Gsc_to_Gsw) + +Conversion of a stomatal conductance for H₂O into stomatal conductance for CO₂. +""" +function gsw_to_gsc(Gₛ, Gsc_to_Gsw=PlantMeteo.Constants().Gsc_to_Gsw) + Gₛ / Gsc_to_Gsw +end + +""" +γ_star(γ, a_sh, a_s, rbv, Rsᵥ, Rbₕ) + +γ∗, the apparent value of psychrometer constant (kPa K−1). + +# Arguments + +- `γ` (kPa K−1): psychrometer constant +- `aₛₕ` (1,2): number of faces exchanging heat fluxes (see Schymanski et al., 2017) +- `aₛᵥ` (1,2): number of faces exchanging water fluxes (see Schymanski et al., 2017) +- `Rbᵥ` (s m-1): boundary layer resistance to water vapor +- `Rsᵥ` (s m-1): stomatal resistance to water vapor +- `Rbₕ` (s m-1): boundary layer resistance to heat + +# Note + +Using the corrigendum from Schymanski et al. (2017) in here so the definition of +[`latent_heat`](@ref) remains generic. + +Not to be confused with [`Γ_star`](@ref) the CO₂ compensation point. + +# References + +Monteith, John L., et Mike H. Unsworth. 2013. « Chapter 13 - Steady-State Heat Balance: (i) +Water Surfaces, Soil, and Vegetation ». In Principles of Environmental Physics (Fourth Edition), +edited by John L. Monteith et Mike H. Unsworth, 217‑47. Boston: Academic Press. + +Schymanski, Stanislaus J., et Dani Or. 2017. Leaf-Scale Experiments Reveal an Important +Omission in the Penman–Monteith Equation ». Hydrology and Earth System Sciences 21 (2): 685‑706. +https://doi.org/10.5194/hess-21-685-2017. +""" +function γ_star(γ, aₛₕ, aₛᵥ, Rbᵥ, Rsᵥ, Rbₕ) + γ * aₛₕ / aₛᵥ * (Rbᵥ + Rsᵥ) / Rbₕ # rv + Rsᵥ= Boundary + stomatal conductance to water vapour +end + +""" + λE_to_E(λE, λ, Mₕ₂ₒ=PlantMeteo.Constants().Mₕ₂ₒ) + E_to_λE(E, λ, Mₕ₂ₒ=PlantMeteo.Constants().Mₕ₂ₒ) + +Conversion from latent heat (W m-2) to evaporation (mol[H₂O] m-2 s-1) or the +opposite (`E_to_λE`). + +# Arguments + +- `λE`: latent heat flux (W m-2) +- `E`: water evaporation (mol[H₂O] m-2 s-1) +- `λ` (J kg-1): latent heat of vaporization +- `Mₕ₂ₒ = 18.0e-3` (kg mol-1): Molar mass for water. + +# Note + +`λ` can be computed using: + + λ = latent_heat_vaporization(T, constants.λ₀) + +It is also directly available from the [`Atmosphere`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.Atmosphere) structure, and by extention in [`Weather`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.Weather). + +To convert E from mol[H₂O] m-2 s-1 to mm s-1 you can simply do: + + E_mms = E_mol / constants.Mₕ₂ₒ + +mm[H₂O] s-1 is equivalent to kg[H₂O] m-2 s-1, wich is equivalent to l[H₂O] m-2 s-1. + +""" +function λE_to_E(λE, λ, Mₕ₂ₒ=PlantMeteo.Constants().Mₕ₂ₒ) + λE / λ * Mₕ₂ₒ +end + +function E_to_λE(E, λ, Mₕ₂ₒ=PlantMeteo.Constants().Mₕ₂ₒ) + E / Mₕ₂ₒ * λ +end + +""" +Struct to hold parameter and values for the energy model close to the one in +Monteith and Unsworth (2013) + +# Arguments + +- `aₛₕ = 2`: number of faces of the object that exchange sensible heat fluxes +- `aₛᵥ = 1`: number of faces of the object that exchange latent heat fluxes (hypostomatous => 1) +- `ε = 0.955`: emissivity of the object +- `maxiter = 10`: maximal number of iterations allowed to close the energy balance +- `ΔT = 0.01` (°C): maximum difference in object temperature between two iterations to consider convergence + +# Examples + +```julia +energy_model = Monteith() # a leaf in an illuminated chamber +``` +""" +struct Monteith{T,S} <: AbstractEnergy_BalanceModel + aₛₕ::S + aₛᵥ::S + ε::T + maxiter::S + ΔT::T +end + +function Monteith(; aₛₕ=2, aₛᵥ=1, ε=0.955, maxiter=10, ΔT=0.01) + param_int = promote(aₛₕ, aₛᵥ, maxiter) + param_float = promote(ε, ΔT) + Monteith(param_int[1], param_int[2], param_float[1], param_int[3], param_float[2]) +end + +function PlantSimEngine.inputs_(::Monteith) + (Ra_SW_f=-Inf, sky_fraction=-Inf, d=-Inf) +end + +function PlantSimEngine.outputs_(::Monteith) + ( + Tₗ=-Inf, Rn=-Inf, Ra_LW_f=-Inf, H=-Inf, λE=-Inf, Cₛ=-Inf, Cᵢ=-Inf, + A=-Inf, Gₛ=-Inf, Gbₕ=-Inf, Dₗ=-Inf, Gbc=-Inf, iter=typemin(Int) + ) +end + +Base.eltype(x::Monteith) = typeof(x).parameters[1] +# Multi-rate default for energy balance: keep relatively fine cadence. +PlantSimEngine.timestep_hint(::Type{<:Monteith}) = ( + required=(Dates.Minute(1), Dates.Hour(2)), + preferred=Dates.Hour(1) +) +PlantSimEngine.output_policy(::Type{<:Monteith}) = ( + A=PlantSimEngine.Integrate(PlantMeteo.DurationSumReducer()), + Tₗ=PlantSimEngine.Integrate(PlantMeteo.MeanReducer()), + Rn=PlantSimEngine.Integrate(PlantMeteo.RadiationEnergy()), # W m-2 to MJ m-2 timestep-1 + Ra_LW_f=PlantSimEngine.Integrate(PlantMeteo.RadiationEnergy()), + H=PlantSimEngine.Integrate(PlantMeteo.RadiationEnergy()), + λE=PlantSimEngine.Integrate(PlantMeteo.RadiationEnergy()), + Cₛ=PlantSimEngine.Integrate(PlantMeteo.MeanReducer()), + Cᵢ=PlantSimEngine.Integrate(PlantMeteo.MeanReducer()), + Gₛ=PlantSimEngine.Integrate(PlantMeteo.DurationSumReducer()), + Gbₕ=PlantSimEngine.Integrate(PlantMeteo.DurationSumReducer()), + Dₗ=PlantSimEngine.Integrate(PlantMeteo.MeanReducer()), + Gbc=PlantSimEngine.Integrate(PlantMeteo.DurationSumReducer()), + iter=PlantSimEngine.Integrate(PlantMeteo.MeanReducer()) +) + +PlantSimEngine.dep(::Monteith) = ( + photosynthesis=PlantSimEngine.Call( + PlantSimEngine.One(scale=:Leaf, process=:photosynthesis), + ), +) + +""" + run!(::Monteith, models, status, meteo, constants=Constants()) + +Leaf energy balance according to Monteith and Unsworth (2013), and corrigendum from +Schymanski et al. (2017). The computation is close to the one from the MAESPA model (Duursma +et al., 2012, Vezy et al., 2018) here. The leaf temperature is computed iteratively to close +the energy balance using the mass flux (~ Rn - λE). + +# Arguments + +- `::Monteith`: a Monteith model, usually from a model list (*i.e.* m.energy_balance) +- `models`: the process-keyed model bundle supplied by the model runtime, with +initialisations for: + - `Ra_SW_f` (W m-2): net shortwave radiation (PAR + NIR). Often computed from a light interception model + - `sky_fraction` (0-2): view factor between the object and the sky for both faces (see details). + - `d` (m): characteristic dimension, *e.g.* leaf width (see eq. 10.9 from Monteith and Unsworth, 2013). +- `status`: the status of the model, usually the model list status (*i.e.* leaf.status) +- `meteo`: meteorology structure, see [`Atmosphere`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.Atmosphere) +- `constants = PlantMeteo.Constants()`: physical constants. See `PlantMeteo.Constants` for more details + +# Details + +The sky_fraction in the variables is equal to 2 if all the leaf is viewing is sky (e.g. in a +controlled chamber), 1 if the leaf is *e.g.* up on the canopy where the upper side of the +leaf sees the sky, and the side bellow sees soil + other leaves that are all considered at +the same temperature than the leaf, or less than 1 if it is partly shaded. + +# Notes + +If you want the algorithm to print a message whenever it does not reach convergence, use the +debugging mode by executing this in the REPL: `ENV["JULIA_DEBUG"] = PlantBiophysics`. + +More information [here](https://docs.julialang.org/en/v1/stdlib/Logging/#Environment-variables). + +# References + +Duursma, R. A., et B. E. Medlyn. 2012. « MAESPA: a model to study interactions between water +limitation, environmental drivers and vegetation function at tree and stand levels, with an +example application to [CO2] × drought interactions ». Geoscientific Model Development 5 (4): +919‑40. https://doi.org/10.5194/gmd-5-919-2012. + +Monteith, John L., et Mike H. Unsworth. 2013. « Chapter 13 - Steady-State Heat Balance: (i) +Water Surfaces, Soil, and Vegetation ». In Principles of Environmental Physics (Fourth Edition), +edited by John L. Monteith et Mike H. Unsworth, 217‑47. Boston: Academic Press. + +Schymanski, Stanislaus J., et Dani Or. 2017. « Leaf-Scale Experiments Reveal an Important +Omission in the Penman–Monteith Equation ». Hydrology and Earth System Sciences 21 (2): 685‑706. +https://doi.org/10.5194/hess-21-685-2017. + +Vezy, Rémi, Mathias Christina, Olivier Roupsard, Yann Nouvellon, Remko Duursma, Belinda Medlyn, +Maxime Soma, et al. 2018. « Measuring and modelling energy partitioning in canopies of varying +complexity using MAESPA model ». Agricultural and Forest Meteorology 253‑254 (printemps): 203‑17. +https://doi.org/10.1016/j.agrformet.2018.02.005. +""" +function PlantSimEngine.run!(::Monteith, models, status, meteo, constants=PlantMeteo.Constants(), extra=nothing) + + # Initialisations + status.Tₗ = meteo.T - 0.2 + Tₗ_new = zero(meteo.T) + status.Cₛ = meteo.Cₐ + status.Dₗ = PlantMeteo.e_sat(status.Tₗ) - PlantMeteo.e_sat(meteo.T) * meteo.Rh + γˢ = Rbₕ = Δ = zero(meteo.T) + status.Rn = status.Ra_SW_f + iter = 0 + # ?NB: We use iter = 0 and not 1 to get the right number of iterations at the end + # of the for loop, because we use iter += 1 at the end (so it increments once again) + + # Iterative resolution of the energy balance + for i in 1:models.energy_balance.maxiter + + # Update A, Gₛ, Cᵢ from models.status: + PlantSimEngine.run_call!( + extra, + :photosynthesis; + meteo=meteo, + publish=false, + ) + + # Stomatal resistance to water vapor + Rsᵥ = 1.0 / (gsc_to_gsw(mol_to_ms(status.Gₛ, meteo.T, meteo.P, constants.R, constants.K₀), + constants.Gsc_to_Gsw)) + + # Re-computing the net radiation according to simulated leaf temperature: + status.Ra_LW_f = net_longwave_radiation(status.Tₗ, meteo.T, models.energy_balance.ε, meteo.ε, + status.sky_fraction, constants.K₀, constants.σ) + #= ? NB: we use the sky fraction here (0-2) instead of the view factor (0-1) because: + - we consider both sides of the leaf at the same time (1 -> leaf sees sky on one face) + - we consider all objects in the model have the same temperature as the leaf + of interest except the atmosphere. So the leaf exchange thermal energy_balance only with + the atmosphere. =# + # status.Ra_LW_f = (grey_body(meteo.T,1.0) - grey_body(status.Tₗ, 1.0))*status.sky_fraction + + status.Rn = status.Ra_SW_f + status.Ra_LW_f + + # Leaf boundary conductance for heat (m s-1), one sided: + status.Gbₕ = gbₕ_free(meteo.T, status.Tₗ, status.d, constants.Dₕ₀) + + gbₕ_forced(meteo.Wind, status.d) + # NB, in MAESPA we use Rni so we add the radiation conductance also (not here) + + # Leaf boundary resistance for heat (s m-1): + Rbₕ = 1 / status.Gbₕ + + # Leaf boundary resistance for water vapor (s m-1): + Rbᵥ = 1 / gbh_to_gbw(status.Gbₕ) + + # Leaf boundary conductance for CO₂ (mol[CO₂] m-2 s-1): + status.Gbc = ms_to_mol(status.Gbₕ, meteo.T, meteo.P, constants.R, constants.K₀) / + constants.Gbc_to_Gbₕ + + # Update Cₛ using boundary layer conductance to CO₂ and assimilation: + status.Cₛ = min(meteo.Cₐ, meteo.Cₐ - status.A / (status.Gbc * models.energy_balance.aₛᵥ)) + + # Apparent value of psychrometer constant (kPa K−1) + γˢ = γ_star(meteo.γ, models.energy_balance.aₛₕ, models.energy_balance.aₛᵥ, Rbᵥ, Rsᵥ, Rbₕ) + + status.λE = latent_heat(status.Rn, meteo.VPD, γˢ, Rbₕ, meteo.Δ, meteo.ρ, + models.energy_balance.aₛₕ, constants.Cₚ) + + # If potential evaporation is needed, here is how to compute it: + # γˢₑ = γ_star(meteo.γ, energy_balance.aₛₕ, 1, Rbᵥ, 1.0e-9, Rbₕ) # Rsᵥ is inf. low + # Ev = latent_heat(status.Rn, meteo.VPD, γˢₑ, Rbₕ, meteo.Δ, meteo.ρ, energy_balance.aₛₕ, constants.Cₚ) + + Tₗ_new = meteo.T + (status.Rn - status.λE) / + (meteo.ρ * constants.Cₚ * (models.energy_balance.aₛₕ / Rbₕ)) + + if abs(Tₗ_new - status.Tₗ) <= models.energy_balance.ΔT + break + end + + status.Tₗ = Tₗ_new + + # Vapour pressure difference between the surface and the saturation vapour pressure: + status.Dₗ = PlantMeteo.e_sat(status.Tₗ) - PlantMeteo.e_sat(meteo.T) * meteo.Rh + + iter += 1 + end + + status.H = sensible_heat(status.Rn, meteo.VPD, γˢ, Rbₕ, meteo.Δ, meteo.ρ, + models.energy_balance.aₛₕ, constants.Cₚ) + + status.iter = iter + + @debug begin + if iter == models.energy_balance.maxiter + "`run!` algorithm did not converge. Please check the value." + end + end + + # Transpiration (mol[H₂O] m-2 s-1): + # ET = status.λE / meteo.λ * constants.Mₕ₂ₒ + # ET / constants.Mₕ₂ₒ to get mm s-1 <=> kg m-2 s-1 <=> l m-2 s-1 + + nothing +end + +""" + latent_heat(Rn, VPD, γˢ, Rbₕ, Δ, ρ, aₛₕ, Cₚ) + latent_heat(Rn, VPD, γˢ, Rbₕ, Δ, ρ, aₛₕ) + +λE -the latent heat flux (W m-2)- using the Monteith and Unsworth (2013) definition corrected by +Schymanski et al. (2017), eq.22. + +- `Rn` (W m-2): net radiation. Carefull: not the isothermal net radiation +- `VPD` (kPa): air vapor pressure deficit +- `γˢ` (kPa K−1): apparent value of psychrometer constant (see `PlantMeteo.γ_star`) +- `Rbₕ` (s m-1): resistance for heat transfer by convection, i.e. resistance to sensible heat +- `Δ` (KPa K-1): rate of change of saturation vapor pressure with temperature (see `PlantMeteo.e_sat_slope`) +- `ρ` (kg m-3): air density of moist air. +- `aₛₕ` (1,2): number of sides that exchange energy for heat (2 for leaves) +- `Cₚ` (J K-1 kg-1): specific heat of air for constant pressure + +# References + +Monteith, J. and Unsworth, M., 2013. Principles of environmental physics: plants, animals, and the atmosphere. Academic Press. See eq. 13.33. + +Schymanski et al. (2017), Leaf-scale experiments reveal an important omission in the Penman–Monteith equation, +Hydrology and Earth System Sciences. DOI: https://doi.org/10.5194/hess-21-685-2017. See equ. 22. + +# Examples + +```julia +Tₐ = 20.0 ; P = 100.0 ; +ρ = air_density(Tₐ, P) # in kg m-3 +Δ = e_sat_slope(Tₐ) + +latent_heat(300.0, 2.0, 0.1461683, 50.0, Δ, ρ, 2.0) +``` +""" +function latent_heat(Rn, VPD, γˢ, Rbₕ, Δ, ρ, aₛₕ, Cₚ) + (Δ * Rn + ρ * Cₚ * VPD * (aₛₕ / Rbₕ)) / (Δ + γˢ) +end + +function latent_heat(Rn, VPD, γˢ, Rbₕ, Δ, ρ, aₛₕ) + latent_heat(Rn, VPD, γˢ, Rbₕ, Δ, ρ, aₛₕ, PlantMeteo.Constants().Cₚ) +end + + +""" + sensible_heat(Rn, VPD, γˢ, Rbₕ, Δ, ρ, aₛₕ, Cₚ) + sensible_heat(Rn, VPD, γˢ, Rbₕ, Δ, ρ, aₛₕ) + +H -the sensible heat flux (W m-2)- using the Monteith and Unsworth (2013) definition corrected by +Schymanski et al. (2017), eq.22. + +- `Rn` (W m-2): net radiation. Carefull: not the isothermal net radiation +- `VPD` (kPa): air vapor pressure deficit +- `γˢ` (kPa K−1): apparent value of psychrometer constant (see `PlantMeteo.γ_star`) +- `Rbₕ` (s m-1): resistance for heat transfer by convection, i.e. resistance to sensible heat +- `Δ` (KPa K-1): rate of change of saturation vapor pressure with temperature (see `PlantMeteo.e_sat_slope`) +- `ρ` (kg m-3): air density of moist air. +- `aₛₕ` (1,2): number of sides that exchange energy for heat (2 for leaves) +- `Cₚ` (J K-1 kg-1): specific heat of air for constant pressure + +# References + +Monteith, J. and Unsworth, M., 2013. Principles of environmental physics: plants, animals, and the atmosphere. Academic Press. See eq. 13.33. + +Schymanski et al. (2017), Leaf-scale experiments reveal an important omission in the Penman–Monteith equation, +Hydrology and Earth System Sciences. DOI: https://doi.org/10.5194/hess-21-685-2017. See equ. 22. + +# Examples + +```julia +Tₐ = 20.0 ; P = 100.0 ; +ρ = air_density(Tₐ, P) # in kg m-3 +Δ = PlantMeteo.e_sat_slope(Tₐ) + +sensible_heat(300.0, 2.0, 0.1461683, 50.0, Δ, ρ, 2.0) +``` +""" +function sensible_heat(Rn, VPD, γˢ, Rbₕ, Δ, ρ, aₛₕ, Cₚ) + (γˢ * Rn - ρ * Cₚ * VPD * (aₛₕ / Rbₕ)) / (Δ + γˢ) +end + +function sensible_heat(Rn, VPD, γˢ, Rbₕ, Δ, ρ, aₛₕ) + sensible_heat(Rn, VPD, γˢ, Rbₕ, Δ, ρ, aₛₕ, PlantMeteo.Constants().Cₚ) +end diff --git a/examples/plantbiophysics_subsample/Tuzet.jl b/examples/plantbiophysics_subsample/Tuzet.jl new file mode 100644 index 000000000..ad7326df2 --- /dev/null +++ b/examples/plantbiophysics_subsample/Tuzet.jl @@ -0,0 +1,112 @@ +#! Careful: this file is more or less a copy/paste from the original model implementation in PlantBiophysics.jl (v0.16.2). It is only used for testing. +#! If you want to use this model, use the one from PlantBiophysics.jl instead, which is more up to date and maintained. + +# Generate all methods for the stomatal conductance process: several meteo time-steps, components, +# over an MTG, and the mutating /non-mutating versions +@process "stomatal_conductance" verbose = false + +# Default policy for stomatal conductance when consumed at coarser clocks. +# Conductance is typically summarized over a window rather than accumulated. +PlantSimEngine.output_policy(::Type{<:AbstractStomatal_ConductanceModel}) = (Gₛ=PlantSimEngine.Aggregate(PlantMeteo.DurationSumReducer()),) + +# Gs is used a little bit differently compared to the other processes. We use two forms: +# the stomatal closure and the full computation of Gs +function PlantSimEngine.run!(Gs::Gsm, models, status, gs_closure, extra) where {Gsm<:AbstractStomatal_ConductanceModel} + status.Gₛ = max( + models.stomatal_conductance.gs_min, + models.stomatal_conductance.g0 + gs_closure * status.A + ) +end + +function PlantSimEngine.run!(Gs::Gsm, models, status, meteo, constants, extra) where {Gsm<:AbstractStomatal_ConductanceModel} + status.Gₛ = max( + models.stomatal_conductance.gs_min, + models.stomatal_conductance.g0 + gs_closure(models.stomatal_conductance, models, status, meteo, constants, extra) * status.A + ) +end + +""" +Tuzet et al. (2003) stomatal conductance model for CO₂. + +# Arguments + +- `g0`: intercept (μmol m⁻² s⁻¹). +- `g1`: slope. +- `Ψᵥ`: leaf water potential at which stomatal conductance is halved (MPa). +- `sf`: sensitivity factor for stomatal closure. +- `Γ`: CO₂ compensation point (mol mol⁻¹). +- `gs_min`: residual conductance (μmol m⁻² s⁻¹). + +# Variables + +- `Ψₗ`: leaf water potential (MPa). +- `Cₛ`: CO₂ concentration at the leaf surface (μmol mol⁻¹). +- `A`: CO₂ assimilation rate (μmol m⁻² s⁻¹). +- `Gₛ`: stomatal conductance (μmol m⁻² s⁻¹). + +# Note + +The CO₂ compensation point represents the concentration of CO₂ at which photosynthesis and respiration are balanced, +and it is typically a small positive value around 30–50 μmol mol⁻¹ under normal atmospheric conditions. + +This implementation uses Cₛ instead of Cᵢ. + +# References + +Tuzet, A., Perrier, A., & Leuning, R. (2003). A coupled model of stomatal conductance, photosynthesis and transpiration. Plant, Cell & Environment, 26(7), 1097-1116. +""" +struct Tuzet{T} <: AbstractStomatal_ConductanceModel + g0::T + g1::T + Ψᵥ::T + sf::T + Γ::T + gs_min::T +end + +Tuzet(g0, g1, Ψᵥ, sf, Γ, gs_min=oftype(g0, 0.001)) = Tuzet(promote(g0, g1, Ψᵥ, sf, Γ, gs_min)) +Tuzet(; g0, g1, Ψᵥ, sf, Γ, gs_min=0.001) = Tuzet(g0, g1, Ψᵥ, sf, Γ, gs_min) + +function PlantSimEngine.inputs_(::Tuzet) + (Ψₗ=-Inf, Cₛ=-Inf) +end + +function PlantSimEngine.outputs_(::Tuzet) + (Gₛ=-Inf,) +end + +Base.eltype(::Tuzet{T}) where T = T + +""" + gs_closure(::Tuzet, models, status, meteo, constants=nothing, extra=nothing) + +Stomatal closure for CO₂ according to Tuzet et al. (2003). + +# Arguments + +- `::Tuzet`: an instance of the `Tuzet` model type. +- `models`: the process-keyed model bundle supplied by the model runtime. +- `status`: A status struct holding the variables for the models. +- `meteo`: meteorology structure, see [`Atmosphere`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.Atmosphere). Is not used in this model. +- `constants`: A constants struct holding the constants for the models. Is not used in this model. +- `extra`: A struct holding the extra variables for the models. Is not used in this model. + +# Details + +The stomatal conductance is calculated as: + + FPSIF = (1 + exp(sf * psiv)) / (1 + exp(sf * (psiv - Ψₗ))) + GSDIVA = g0 + (g1 / (Cₛ - Γ)) * FPSIF + +where `Γ` is the CO₂ compensation point. +""" +function gs_closure(m::Tuzet, models, status, meteo, constants=nothing, extra=nothing) + fpsif = (1 + exp(m.sf * m.Ψᵥ)) / + (1 + exp(m.sf * (m.Ψᵥ - status.Ψₗ))) + (m.g1 / (status.Cₛ - m.Γ)) * fpsif +end + +PlantSimEngine.timestep_hint(::Type{<:Tuzet}) = ( + required=(Dates.Minute(1), Dates.Hour(6)), + preferred=Dates.Hour(1) +) diff --git a/ext/PlantSimEngineGraphEditorExt.jl b/ext/PlantSimEngineGraphEditorExt.jl new file mode 100644 index 000000000..bef80c018 --- /dev/null +++ b/ext/PlantSimEngineGraphEditorExt.jl @@ -0,0 +1,991 @@ +module PlantSimEngineGraphEditorExt + +import HTTP +import JSON +import PlantSimEngine +import PlantSimEngine: edit_graph, current_model, apply_edit!, undo!, redo! + +mutable struct GraphEditorSession <: PlantSimEngine.AbstractModelGraphEditorSession + model::PlantSimEngine.CompositeModel + history::Vector{Any} + future::Vector{Any} + server::Any + host::String + port::Int + token::String + url::String + autosave_path::Union{Nothing,String} + save_path::Union{Nothing,String} + allow_julia_eval::Bool + recent_paths::Vector{String} +end + +current_model(session::GraphEditorSession) = session.model + +function Base.close(session::GraphEditorSession) + try + isopen(session.server) && close(session.server) + catch + close(session.server) + end + return nothing +end + +function Base.show(io::IO, session::GraphEditorSession) + print(io, "GraphEditorSession(url=$(repr(session.url)), applications=$(length(session.model.applications)))") +end + +function Base.show(io::IO, ::MIME"text/plain", session::GraphEditorSession) + println(io, "PlantSimEngineGraphEditorExt.GraphEditorSession") + println(io, " Open in browser: $(session.url)") + println(io, " State JSON: $(_state_url(session))") + println(io, " Current model: current_model(session)") + println(io, " Quit session: close(session)") + isnothing(session.autosave_path) || println(io, " Recovery autosave: $(session.autosave_path)") + isnothing(session.save_path) || println(io, " Saving changes to: $(session.save_path)") +end + +""" + edit_graph([model]; host="127.0.0.1", port=0, open_browser=true, + autosave=true, allow_remote=false, allow_julia_eval=nothing) + +Start a local Model graph editor. Julia owns the current Composite model and applies all +semantic edits received from the browser. Call `edit_graph()` to start from an +empty Composite model and `close(session)` to stop the server. +""" +function edit_graph( + model::PlantSimEngine.CompositeModel=PlantSimEngine.CompositeModel(); + host::AbstractString="127.0.0.1", + port::Integer=0, + open_browser::Bool=true, + autosave::Bool=true, + autosave_path::Union{Nothing,AbstractString}=nothing, + save_path::Union{Nothing,AbstractString}=nothing, + allow_remote::Bool=false, + allow_julia_eval::Union{Nothing,Bool}=nothing, + recover_path::Union{Nothing,AbstractString}=nothing, + recent_paths=nothing, +) + _is_loopback_host(host) || allow_remote || error( + "Graph editor sessions are limited to localhost by default. Pass `allow_remote=true` only for a trusted network.", + ) + effective_allow_julia_eval = isnothing(allow_julia_eval) ? !allow_remote : allow_julia_eval + initial_model = isnothing(recover_path) ? deepcopy(model) : _load_model_file( + _normalized_path(recover_path); + allow_julia_eval=effective_allow_julia_eval, + ) + session_ref = Ref{Any}() + handler = stream -> _handle_http(session_ref[], stream) + server = HTTP.listen!(handler, host, port; listenany=true, verbose=false) + actual_port = HTTP.port(server) + token = _session_token(server) + autosave_file = autosave ? _normalized_path( + isnothing(autosave_path) ? _default_autosave_path() : autosave_path, + ) : nothing + remembered_paths = isnothing(recent_paths) ? _load_recent_paths() : String.(recent_paths) + session = GraphEditorSession( + initial_model, + Any[], + Any[], + server, + String(host), + actual_port, + token, + "http://$(host):$(actual_port)/?token=$(token)", + autosave_file, + isnothing(save_path) ? nothing : _normalized_path(save_path), + effective_allow_julia_eval, + String[_normalized_path(path) for path in remembered_paths], + ) + session_ref[] = session + isnothing(session.save_path) || _remember_path!(session, session.save_path) + isnothing(recover_path) || _remember_path!(session, _normalized_path(recover_path)) + _persist_model!(session) + open_browser && _open_in_default_browser(session.url) + return session +end + +function apply_edit!(session::GraphEditorSession, edit::PlantSimEngine.AbstractModelGraphEdit) + candidate = PlantSimEngine.apply_model_graph_edit(session.model, edit) + push!(session.history, session.model) + empty!(session.future) + session.model = candidate + _persist_model!(session) + return session.model +end + +function undo!(session::GraphEditorSession) + isempty(session.history) && return session.model + push!(session.future, session.model) + session.model = pop!(session.history) + _persist_model!(session) + return session.model +end + +function redo!(session::GraphEditorSession) + isempty(session.future) && return session.model + push!(session.history, session.model) + session.model = pop!(session.future) + _persist_model!(session) + return session.model +end + +_session_token(server) = string(hash((time_ns(), getpid(), objectid(server))); base=16) * + string(hash((objectid(server), time_ns(), getpid())); base=16) + +function _is_loopback_host(host) + return lowercase(strip(String(host))) in ( + "127.0.0.1", + "localhost", + "::1", + "[::1]", + "0:0:0:0:0:0:0:1", + ) +end + +_base_url(session) = "http://$(session.host):$(session.port)" +_state_url(session) = "$(_base_url(session))/state?token=$(session.token)" +_websocket_url(session) = "ws://$(session.host):$(session.port)/ws?token=$(session.token)" + +function _handle_http(session::GraphEditorSession, stream::HTTP.Stream) + request = stream.message + path = HTTP.URI(request.target).path + + if HTTP.WebSockets.isupgrade(request) + _authorized_request(session, request) || return _write_response(stream, 403, "text/plain", "Forbidden session token.") + _authorized_origin(session, request) || return _write_response(stream, 403, "text/plain", "Forbidden websocket origin.") + return HTTP.WebSockets.upgrade(stream) do websocket + _handle_websocket(session, websocket) + end + end + + if path == "/health" + return _write_response(stream, 200, "application/json", JSON.json(Dict("ok" => true))) + end + _authorized_request(session, request) || return _write_response(stream, 403, "text/plain", "Forbidden session token.") + if path == "/" || path == "/index.html" + return _write_response(stream, 200, "text/html; charset=utf-8", _editor_html(session)) + elseif path == "/static" + view = PlantSimEngine.model_graph_view(session.model) + return _write_response( + stream, + 200, + "text/html; charset=utf-8", + PlantSimEngine.model_graph_view_html(view), + ) + elseif path == "/state" + return _write_response(stream, 200, "application/json", _state_json(session)) + end + return _write_response(stream, 404, "text/plain", "Not found") +end + +function _write_response(stream, status, content_type, body) + HTTP.setstatus(stream, status) + HTTP.setheader(stream, "Content-Type" => content_type) + HTTP.setheader(stream, "Connection" => "close") + HTTP.setheader(stream, "Content-Length" => string(sizeof(body))) + HTTP.startwrite(stream) + write(stream, body) + return nothing +end + +function _authorized_request(session, request) + token = HTTP.header(request, "X-PlantSimEngine-Graph-Token", "") + isempty(token) && (token = something(_query_parameter(request.target, "token"), "")) + return token == session.token +end + +function _query_parameter(target, requested_name) + query = String(HTTP.URI(target).query) + isempty(query) && return nothing + for component in split(query, '&') + pair = split(component, '='; limit=2) + length(pair) == 2 || continue + first(pair) == requested_name && return last(pair) + end + return nothing +end + +function _authorized_origin(session, request) + origin = HTTP.header(request, "Origin", "") + return isempty(origin) || origin == _base_url(session) +end + +function _handle_websocket(session, websocket) + _send_websocket(websocket, _state_json(session)) || return nothing + try + for raw_message in websocket + command = JSON.parse(String(raw_message)) + response = _handle_command!(session, command) + _send_websocket(websocket, JSON.json(response)) || return nothing + end + catch err + _is_close_error(err) || _send_websocket( + websocket, + JSON.json(Dict("ok" => false, "diagnostics" => [sprint(showerror, err)])), + ) + end + return nothing +end + +function _send_websocket(websocket, payload) + try + HTTP.WebSockets.send(websocket, payload) + return true + catch err + _is_close_error(err) && return false + rethrow() + end +end + +_is_close_error(err) = err isa EOFError || err isa Base.IOError + +function _handle_command!(session, command) + action = String(get(command, "action", "")) + try + if action == "undo" + undo!(session) + elseif action == "redo" + redo!(session) + elseif action == "edit" + apply_edit!(session, _edit_from_command(session, command)) + elseif action == "save_model_code" + session.save_path = _normalized_path(String(command["path"])) + _remember_path!(session, session.save_path) + _persist_model!(session) + elseif action == "open_model_code" + path = _normalized_path(String(command["path"])) + candidate = _load_model_file(path; allow_julia_eval=session.allow_julia_eval) + push!(session.history, session.model) + empty!(session.future) + session.model = candidate + session.save_path = path + _remember_path!(session, path) + _persist_model!(session) + elseif action == "preview_input_binding" + return _preview_input_binding_payload(session, command) + elseif action == "preview_application_targets" + return _preview_application_targets_payload(session, command) + elseif action in ("open_add_application", "begin_add_application") + # This command only focuses/prefills frontend state. The Composite model is + # changed by a subsequent add_application edit. + else + error("Unsupported graph editor command action `$(action)`.") + end + return _state_payload(session) + catch err + return _state_payload(session; ok=false, diagnostics=[sprint(showerror, err)]) + end +end + +function _preview_application_targets_payload(session, command) + selector = _selector_from_payload(command["selector"]) + target_ids = PlantSimEngine.resolve_object_ids(session.model, selector) + payload = _state_payload(session) + payload["targetPreview"] = Dict{String,Any}( + "objectIds" => [PlantSimEngine._model_graph_json_value(id.value) for id in target_ids], + "count" => length(target_ids), + ) + return payload +end + +function _preview_input_binding_payload(session, command) + application_id = Symbol(command["applicationId"]) + input = Symbol(command["input"]) + candidate = PlantSimEngine.apply_model_graph_edit( + session.model, + PlantSimEngine.SetModelInputBinding( + application_id, + input, + _selector_from_payload(command["selector"]), + ), + ) + report = PlantSimEngine.compile_model_report(candidate) + bindings = [ + binding for binding in report.input_bindings + if binding.application_id == application_id && binding.input == input + ] + payload = _state_payload(session) + payload["selectorPreview"] = Dict{String,Any}( + "applicationId" => string(application_id), + "input" => string(input), + "consumerObjectIds" => unique([ + PlantSimEngine._model_graph_json_value(binding.consumer_id.value) + for binding in bindings + ]), + "sourceObjectIds" => unique([ + PlantSimEngine._model_graph_json_value(source_id.value) + for binding in bindings for source_id in binding.source_ids + ]), + "sourceApplicationIds" => unique([ + string(source_id) for binding in bindings + for source_id in binding.source_application_ids + ]), + "bindingCount" => length(bindings), + "diagnostics" => [diagnostic.message for diagnostic in report.diagnostics], + ) + return payload +end + +function _edit_from_command(session, command) + kind = String(get(command, "kind", "")) + application_id = Symbol(get(command, "applicationId", "")) + kind == "remove_application" && return PlantSimEngine.RemoveModelApplication(application_id) + kind == "remove_template_application" && return PlantSimEngine.RemoveModelTemplateApplication( + command["instance"], + application_id, + ) + kind == "mark_previous_timestep" && return PlantSimEngine.MarkModelPreviousTimeStep( + application_id, + Symbol(command["input"]), + ) + kind == "unmark_previous_timestep" && return PlantSimEngine.UnmarkModelPreviousTimeStep( + application_id, + Symbol(command["input"]), + ) + kind == "break_cycle" && return PlantSimEngine.BreakModelCycle( + application_id, + Symbol(command["input"]), + Bool(get(command, "initializeMissing", false)), + _parameter_value(session, get(command, "initialValue", nothing)), + ) + kind == "set_application_targets" && return PlantSimEngine.SetModelApplicationTargets( + application_id, + _selector_from_payload(command["selector"]), + ) + kind == "set_input_binding" && return PlantSimEngine.SetModelInputBinding( + application_id, + Symbol(command["input"]), + _selector_from_payload(command["selector"]), + ) + kind == "remove_input_binding" && return PlantSimEngine.RemoveModelInputBinding( + application_id, + Symbol(command["input"]), + ) + kind == "set_call_binding" && return PlantSimEngine.SetModelCallBinding( + application_id, + Symbol(command["call"]), + _selector_from_payload(command["selector"]), + ) + kind == "remove_call_binding" && return PlantSimEngine.RemoveModelCallBinding( + application_id, + Symbol(command["call"]), + ) + kind == "set_timestep" && return PlantSimEngine.SetModelApplicationTimeStep( + application_id, + _timestep_from_payload(get(command, "timestep", nothing)), + ) + kind == "set_application_environment" && return PlantSimEngine.SetModelApplicationEnvironment( + application_id, + _configuration_from_payload(session, get(command, "configuration", nothing)), + ) + kind == "set_environment_provider" && return PlantSimEngine.SetModelApplicationEnvironment( + application_id, + _environment_with_provider(session.model, application_id, command["provider"]), + ) + kind == "set_output_routing" && return PlantSimEngine.SetModelOutputRouting( + application_id, + Symbol(command["output"]), + Symbol(command["route"]), + ) + kind == "set_update_ordering" && return PlantSimEngine.SetModelUpdateOrdering( + application_id, + _updates_from_payload(get(command, "updates", Any[])), + ) + kind == "set_object_status" && return PlantSimEngine.SetModelObjectStatus( + command["objectId"], + Symbol(command["variable"]), + _parameter_value(session, command["value"]), + ) + kind == "set_object_statuses" && return PlantSimEngine.SetModelObjectStatuses( + command["objectIds"], + Symbol(command["variable"]), + _parameter_value(session, command["value"]), + ) + kind == "remove_object_status" && return PlantSimEngine.RemoveModelObjectStatus( + command["objectId"], + Symbol(command["variable"]), + ) + kind in ("set_object_metadata", "update_object") && return PlantSimEngine.SetModelObjectMetadata( + PlantSimEngine.ObjectId(command["objectId"]), + _metadata_from_payload(get(command, "configuration", Dict())), + ) + kind == "add_object" && return PlantSimEngine.AddModelObject( + _object_from_command(session, command), + ) + kind == "remove_object" && return PlantSimEngine.RemoveModelObject( + command["objectId"]; + recursive=Bool(get(command, "recursive", true)), + ) + kind == "reparent_object" && return PlantSimEngine.ReparentModelObject( + command["objectId"], + get(command, "parentId", nothing), + ) + kind == "set_instance_override" && return PlantSimEngine.SetModelInstanceOverride( + command["instance"], + application_id, + _construct_model(session, command["modelType"], get(command, "parameters", Dict())), + ) + kind == "remove_instance_override" && return PlantSimEngine.RemoveModelInstanceOverride( + command["instance"], + application_id, + ) + kind == "set_object_override" && return PlantSimEngine.SetModelObjectOverride( + command["instance"], + command["objectId"], + application_id, + _construct_model(session, command["modelType"], get(command, "parameters", Dict())), + ) + kind == "remove_object_override" && return PlantSimEngine.RemoveModelObjectOverride( + command["instance"], + command["objectId"], + application_id, + ) + kind == "add_application" && return _add_application_edit(session, command) + kind == "update_application" && return _update_application_edit(session, command) + kind == "update_template_application" && return PlantSimEngine.UpdateModelTemplateApplication( + Symbol(command["instance"]), + application_id, + _construct_model(session, command["modelType"], get(command, "parameters", Dict())), + _selector_from_payload(command["selector"]), + _timestep_from_payload(get(command, "timestep", nothing)), + ) + kind == "replace_application_model" && return PlantSimEngine.ReplaceModelApplicationModel( + application_id, + _construct_model(session, command["modelType"], get(command, "parameters", Dict())), + ) + error("Unsupported Model graph edit kind `$(kind)`.") +end + +function _environment_with_provider(model, application_id, provider) + spec = PlantSimEngine._model_edit_spec(model, application_id) + current = PlantSimEngine.environment_config(spec) + current isa PlantSimEngine.EnvironmentConfig && (current = current.config) + current_values = current isa NamedTuple ? current : NamedTuple() + return merge(current_values, (provider=Symbol(provider),)) +end + +function _symbol_keyed_namedtuple(payload) + payload isa AbstractDict || error("Expected an object-valued configuration payload.") + return (; (Symbol(key) => value for (key, value) in payload)...) +end + +function _metadata_from_payload(payload) + payload isa AbstractDict || error("Expected an object metadata payload.") + return (; ( + Symbol(key) => (value isa AbstractString && isempty(strip(value)) ? nothing : value) + for (key, value) in payload + )...) +end + +function _configuration_from_payload(session, payload) + isnothing(payload) && return nothing + payload isa AbstractDict || return payload + values = Pair{Symbol,Any}[] + for (key, value) in payload + parsed = if value isa AbstractDict && haskey(value, "type") && haskey(value, "value") + _parameter_value(session, value) + elseif value isa AbstractDict + _configuration_from_payload(session, value) + elseif value isa AbstractVector + [_configuration_from_payload(session, item) for item in value] + else + value + end + push!(values, Symbol(key) => parsed) + end + return (; values...) +end + +function _updates_from_payload(payload) + payload isa AbstractVector || error("Update ordering must be an array.") + return Tuple( + PlantSimEngine.Updates( + Symbol.(get(item, "variables", Any[]))...; + after=Symbol.(get(item, "after", Any[])), + ) + for item in payload + ) +end + +function _object_from_command(session, command) + configuration = get(command, "configuration", Dict()) + status_payload = get(command, "status", Dict()) + status = if isempty(status_payload) + nothing + else + PlantSimEngine.Status((; ( + Symbol(name) => _parameter_value(session, value) + for (name, value) in status_payload + )...)) + end + return PlantSimEngine.Object( + command["objectId"]; + scale=get(configuration, "scale", nothing), + kind=get(configuration, "kind", nothing), + species=get(configuration, "species", nothing), + name=get(configuration, "name", nothing), + parent=get(configuration, "parent", nothing), + status=status, + ) +end + +function _update_application_edit(session, command) + application_id = Symbol(command["applicationId"]) + model = _construct_model(session, command["modelType"], get(command, "parameters", Dict())) + return PlantSimEngine.UpdateModelApplication( + application_id, + model, + Symbol(get(command, "name", string(application_id))), + _selector_from_payload(command["selector"]), + _timestep_from_payload(get(command, "timestep", nothing)), + ) +end + +function _add_application_edit(session, command) + model = _construct_model(session, command["modelType"], get(command, "parameters", Dict())) + name = Symbol(command["name"]) + selector = _selector_from_payload(command["selector"]) + timestep = _timestep_from_payload(get(command, "timestep", nothing)) + spec = PlantSimEngine.ModelSpec( + model; + name=name, + applies_to=selector, + timestep=timestep, + ) + return PlantSimEngine.AddModelApplication(spec) +end + +function _resolve_model_type(label) + text = String(label) + for model_type in PlantSimEngine.available_models() + text in (string(model_type), string(nameof(model_type))) && return model_type + end + error("No loaded model type matches `$(text)`. Load the defining package with `using PackageName` first.") +end + +function _construct_model(session, label, parameters) + model_type = _resolve_model_type(label) + descriptor = PlantSimEngine.model_constructor_descriptor(model_type) + fields = descriptor["fields"] + isempty(fields) && return model_type() + default_instance = try + model_type() + catch + nothing + end + values = Any[] + for field in fields + name = field["name"] + if haskey(parameters, name) + push!(values, _parameter_value(session, parameters[name])) + elseif !isnothing(default_instance) + push!(values, getfield(default_instance, Symbol(name))) + else + error("Missing constructor parameter `$(name)` for model `$(model_type)`.") + end + end + return model_type(values...) +end + +function _parameter_value(session, payload) + payload isa AbstractDict || return payload + choice = Symbol(get(payload, "type", "julia")) + raw = get(payload, "value", nothing) + choice == :float && return parse(Float64, string(raw)) + choice == :integer && return parse(Int, string(raw)) + choice == :boolean && return parse(Bool, string(raw)) + choice == :symbol && return Symbol(raw) + choice == :string && return String(raw) + choice == :nothing && return nothing + choice == :julia && session.allow_julia_eval || choice != :julia || error( + "Raw Julia parameter values are disabled for this session.", + ) + choice == :julia && return Core.eval(Main, Meta.parse(String(raw))) + return raw +end + +function _selector_from_payload(payload) + payload isa AbstractDict || error("A selector payload must be an object.") + multiplicity = Symbol(get(payload, "multiplicity", "many")) + criteria = get(payload, "criteria", Dict()) + selectors = PlantSimEngine.AbstractObjectSelector[ + _selector_atom_from_payload(value) + for value in get(criteria, "selectors", Any[]) + ] + keyword_pairs = Pair{Symbol,Any}[] + for (key, value) in criteria + key == "selectors" && continue + value === nothing && continue + push!(keyword_pairs, Symbol(key) => _selector_value(Symbol(key), value)) + end + keywords = (; keyword_pairs...) + multiplicity == :one && return PlantSimEngine.One(selectors...; keywords...) + multiplicity == :optional_one && return PlantSimEngine.OptionalOne(selectors...; keywords...) + multiplicity == :many && return PlantSimEngine.Many(selectors...; keywords...) + error("Unsupported selector multiplicity `$(multiplicity)`.") +end + +function _selector_value(key, value) + key in (:scale, :kind, :species, :name, :process, :var, :relation, :application) && + return value isa AbstractVector ? Symbol.(value) : Symbol(value) + key == :within && return _selector_atom_from_payload(value) + key == :policy && return _policy_from_payload(value) + return value +end + +function _selector_atom_from_payload(payload) + payload isa AbstractDict || error("A structured object selector must be an object.") + type = String(get(payload, "type", "")) + type == "SceneScope" && return PlantSimEngine.SceneScope() + type == "Self" && return PlantSimEngine.Self() + type == "Subtree" && return PlantSimEngine.Subtree() + type == "SelfPlant" && return PlantSimEngine.SelfPlant() + type == "Ancestor" && return PlantSimEngine.Ancestor(; scale=get(payload, "scale", nothing)) + type == "Scope" && return PlantSimEngine.Scope(payload["name"]) + type == "Kind" && return PlantSimEngine.Kind(payload["kind"]) + type == "Species" && return PlantSimEngine.Species(payload["species"]) + type == "Scale" && return PlantSimEngine.Scale(payload["scale"]) + type == "Relation" && return PlantSimEngine.Relation(payload["relation"]) + error("Unsupported structured object selector type `$(type)`.") +end + +function _policy_from_payload(payload) + payload isa AbstractDict || error("A temporal policy must be a structured object.") + type = String(get(payload, "type", "")) + type == "PreviousTimeStep" && return PlantSimEngine.PreviousTimeStep( + Symbol(payload["variable"]), + Symbol(get(payload, "process", "unknown")), + ) + type == "HoldLast" && return PlantSimEngine.HoldLast() + type == "Interpolate" && return PlantSimEngine.Interpolate( + ; + mode=Symbol(get(payload, "mode", "linear")), + extrapolation=Symbol(get(payload, "extrapolation", "linear")), + ) + type == "Integrate" && return PlantSimEngine.Integrate() + type == "Aggregate" && return PlantSimEngine.Aggregate() + error("Unsupported temporal policy type `$(type)`.") +end + +function _timestep_from_payload(payload) + isnothing(payload) && return nothing + payload isa AbstractDict || return payload + mode = String(get(payload, "mode", "default")) + mode == "default" && return nothing + mode == "clock" && return PlantSimEngine.ClockSpec( + parse(Float64, string(payload["dt"])), + parse(Float64, string(get(payload, "phase", 0.0))), + ) + error("Unsupported timestep mode `$(mode)`.") +end + +function _state_payload(session; ok=true, diagnostics=String[]) + graph = JSON.parse(PlantSimEngine.model_graph_view_json(session.model)) + return Dict{String,Any}( + "ok" => ok, + "diagnostics" => diagnostics, + "graph" => graph, + "canUndo" => !isempty(session.history), + "canRedo" => !isempty(session.future), + "url" => session.url, + "modelCode" => _model_to_julia(session.model), + "autosavePath" => session.autosave_path, + "savePath" => session.save_path, + "recentPaths" => session.recent_paths, + ) +end + +function _remember_path!(session, path) + normalized = _normalized_path(path) + filter!(!=(normalized), session.recent_paths) + pushfirst!(session.recent_paths, normalized) + length(session.recent_paths) > 12 && resize!(session.recent_paths, 12) + _persist_recent_paths!(session.recent_paths) + return normalized +end + +function _recent_paths_file() + return joinpath(tempdir(), "PlantSimEngineGraphEditor", "recent-models.json") +end + +function _load_recent_paths() + path = _recent_paths_file() + isfile(path) || return String[] + try + values = JSON.parse(read(path, String)) + values isa AbstractVector || return String[] + return String[_normalized_path(value) for value in values if value isa AbstractString] + catch + return String[] + end +end + +function _persist_recent_paths!(paths) + path = _recent_paths_file() + mkpath(dirname(path)) + _atomic_write(path, JSON.json(collect(paths))) + return path +end + +function _load_model_file(path; allow_julia_eval::Bool) + allow_julia_eval || error("Opening Julia Composite model files is disabled for this editor session.") + isfile(path) || error("Composite model file `$(path)` does not exist.") + module_name = Symbol("PlantSimEngineGraphRecovery_", string(time_ns(); base=16)) + workspace = Module(module_name) + Core.eval(workspace, :(using PlantSimEngine)) + included = Base.include(workspace, path) + model = isdefined(workspace, :model) ? getfield(workspace, :model) : included + model isa PlantSimEngine.CompositeModel || error( + "Composite model file `$(path)` must assign its final PlantSimEngine.CompositeModel to `model`.", + ) + return model +end + +_state_json(session) = JSON.json(_state_payload(session)) + +function _editor_html(session) + view = PlantSimEngine.model_graph_view(session.model) + html = PlantSimEngine.model_graph_view_html(view) + config = replace(JSON.json(Dict("websocketUrl" => _websocket_url(session))), " "<\\/") + script = "" + return replace(html, "" => "$(script)") +end + +function _model_to_julia(model) + io = IOBuffer() + diagnostics = String[] + modules = _model_code_modules(model) + for module_name in sort!(collect(modules)) + println(io, "using $(module_name)") + end + println(io) + if !isnothing(model.source_adapter) + push!(diagnostics, "The Composite model source_adapter is runtime-specific and is not reconstructed by generated code.") + end + for model in _model_code_models(model) + Base.moduleroot(parentmodule(typeof(model))) === Main || continue + push!( + diagnostics, + "Model $(typeof(model)) is defined in Main. Define or include that model before evaluating this generated Composite model script.", + ) + end + for diagnostic in unique(diagnostics) + println(io, "# WARNING: ", diagnostic) + end + isempty(diagnostics) || println(io) + println(io, "objects = (") + for object in PlantSimEngine.model_objects(model) + println(io, " ", _object_code(object), ",") + end + println(io, ")") + + templates = Any[] + for instance in model.instances + any(template -> template === instance.template, templates) || push!(templates, instance.template) + end + for (index, template) in pairs(templates) + println(io) + println(io, "template_$(index) = ", _template_code(template)) + end + + if !isempty(model.instances) + println(io) + println(io, "instances = (") + for instance in model.instances + template_index = only(index for (index, template) in pairs(templates) if template === instance.template) + println(io, " ", _instance_code(instance, template_index), ",") + end + println(io, ")") + else + println(io) + println(io, "instances = ()") + end + + mounted_ids = Set{Symbol}() + for instance in model.instances + union!(mounted_ids, PlantSimEngine._instance_application_ids(model, instance)) + end + global_applications = [ + application for application in model.applications + if PlantSimEngine._model_edit_application_id(application) ∉ mounted_ids + ] + println(io, "applications = (") + for application in global_applications + println(io, " ", _application_code(PlantSimEngine.as_model_spec(application)), ",") + end + println(io, ")") + environment = isnothing(model.environment) ? "nothing" : repr(model.environment) + print(io, "model = CompositeModel(objects...; applications=applications, instances=instances, environment=$(environment))") + return String(take!(io)) +end + +function _model_code_models(model) + models = Any[] + add_application = function (application) + process_model = PlantSimEngine.model_(PlantSimEngine.as_model_spec(application)) + if process_model isa PlantSimEngine.ObjectModelOverrides + push!(models, process_model.base) + append!(models, values(process_model.overrides)) + else + push!(models, process_model) + end + end + foreach(add_application, model.applications) + for object in PlantSimEngine.model_objects(model) + isnothing(object.applications) && continue + foreach(add_application, object.applications) + end + for instance in model.instances + foreach(add_application, instance.template.applications) + append!(models, values(instance.overrides)) + append!(models, (override.model for override in instance.object_overrides)) + end + return models +end + +function _model_code_modules(model) + modules = Set{String}(["PlantSimEngine"]) + add_model = function (model) + module_ = Base.moduleroot(parentmodule(typeof(model))) + module_ in (Base, Core, Main) || push!(modules, string(nameof(module_))) + end + foreach(add_model, _model_code_models(model)) + return modules +end + +function _object_code(object) + keywords = String[ + "scale=$(repr(object.scale))", + "kind=$(repr(object.kind))", + "species=$(repr(object.species))", + "name=$(repr(object.name))", + "parent=$(isnothing(object.parent) ? "nothing" : repr(object.parent.value))", + ] + if object.status isa PlantSimEngine.Status + values = join(("$(name)=$(repr(object.status[name]))" for name in propertynames(object.status)), ", ") + push!(keywords, "status=Status(; $(values))") + end + isnothing(object.geometry) || push!(keywords, "geometry=$(repr(object.geometry))") + if !isnothing(object.applications) && object.applications != () + applications = join( + (_application_code(PlantSimEngine.as_model_spec(application)) for application in object.applications), + ", ", + ) + push!(keywords, "applications=($(applications),)") + end + return "Object($(repr(object.id.value)); $(join(keywords, ", ")))" +end + +function _template_code(template) + applications = join( + (" " * _application_code(PlantSimEngine.as_model_spec(application)) * "," for application in template.applications), + "\n", + ) + return "CompositeModelTemplate((\n$(applications)\n ); kind=$(repr(template.kind)), species=$(repr(template.species)), parameters=$(repr(template.parameters)))" +end + +function _instance_code(instance, template_index) + overrides = if isempty(keys(instance.overrides)) + "NamedTuple()" + else + entries = join(("$(key)=$(repr(model))" for (key, model) in pairs(instance.overrides)), ", ") + "($(entries),)" + end + object_overrides = if isempty(instance.object_overrides) + "()" + else + entries = join((_object_override_code(override) for override in instance.object_overrides), ", ") + "($(entries),)" + end + return "ObjectInstance($(repr(instance.name)), template_$(template_index); root=$(repr(PlantSimEngine._instance_root_id(instance).value)), overrides=$(overrides), object_overrides=$(object_overrides))" +end + +function _object_override_code(override) + options = String["object=$(repr(override.object.value))"] + isnothing(override.process) || push!(options, "process=$(repr(override.process))") + isnothing(override.application) || push!(options, "application=$(repr(override.application))") + push!(options, "model=$(repr(override.model))") + return "Override(; $(join(options, ", ")))" +end + +function _application_code(spec) + code = "ModelSpec($(repr(PlantSimEngine.model_(spec))); name=$(repr(PlantSimEngine.application_name(spec))))" + selector = PlantSimEngine.applies_to(spec) + isnothing(selector) || (code *= " |> AppliesTo($(repr(selector)))") + isempty(keys(PlantSimEngine.value_inputs(spec))) || + (code *= " |> Inputs($(repr(PlantSimEngine.value_inputs(spec))))") + isempty(keys(PlantSimEngine.model_calls(spec))) || + (code *= " |> Calls($(repr(PlantSimEngine.model_calls(spec))))") + environment = PlantSimEngine.environment_config(spec) + if !isnothing(environment) + payload = environment isa PlantSimEngine.EnvironmentConfig ? environment.config : environment + code *= " |> Environment($(repr(payload)))" + end + isnothing(spec.timestep) || (code *= " |> TimeStep($(repr(spec.timestep)))") + if !isempty(keys(PlantSimEngine.meteo_bindings(spec))) + code = "PlantSimEngine.with_meteo_bindings($(code), $(repr(PlantSimEngine.meteo_bindings(spec))))" + end + if !isnothing(PlantSimEngine.meteo_window(spec)) + code = "PlantSimEngine.with_meteo_window($(code), $(repr(PlantSimEngine.meteo_window(spec))))" + end + isempty(keys(PlantSimEngine.output_routing(spec))) || + (code *= " |> OutputRouting($(repr(PlantSimEngine.output_routing(spec))))") + for update in PlantSimEngine.updates(spec) + variables = join(repr.(collect(update.variables)), ", ") + code *= " |> Updates($(variables); after=$(repr(update.after)))" + end + return code +end + +function _persist_model!(session) + code = _model_to_julia(session.model) * "\n" + isnothing(session.autosave_path) || _atomic_write(session.autosave_path, code) + isnothing(session.save_path) || _atomic_write(session.save_path, code) + return nothing +end + +function _atomic_write(path, content) + path = _normalized_path(path) + mkpath(dirname(path)) + temporary = tempname(dirname(path)) + try + write(temporary, content) + mv(temporary, path; force=true) + finally + isfile(temporary) && rm(temporary; force=true) + end + return path +end + +_normalized_path(path) = isabspath(String(path)) ? normpath(String(path)) : normpath(joinpath(pwd(), String(path))) + +function _default_autosave_path() + return joinpath( + tempdir(), + "PlantSimEngineGraphEditor", + string("session-", time_ns()), + "model.autosave.jl", + ) +end + +function _open_in_default_browser(url) + try + if Sys.isapple() + run(`open $url`) + elseif Sys.iswindows() + run(`cmd /c start "" $url`) + elseif !isnothing(Sys.which("xdg-open")) + run(`xdg-open $url`) + else + @warn "Could not locate a default-browser command." url + return false + end + return true + catch err + @warn "Could not open the graph editor automatically." url exception=(err, catch_backtrace()) + return false + end +end + +end diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 000000000..c31b3100a --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,24 @@ +# PlantSimEngine Dependency Graph Viewer + +This is the React Flow frontend for the PlantSimEngine dependency graph viewer. +It consumes the JSON emitted by `PlantSimEngine.graph_view_json`. + +## Development + +```sh +npm install +npm run dev +``` + +The app falls back to a small sample graph when no embedded +` + + + +
+ + diff --git a/frontend/e2e/graph-editor.spec.ts b/frontend/e2e/graph-editor.spec.ts new file mode 100644 index 000000000..6d1f261a0 --- /dev/null +++ b/frontend/e2e/graph-editor.spec.ts @@ -0,0 +1,192 @@ +import { expect, test, type APIRequestContext, type Locator, type Page } from "@playwright/test"; +import type { ApplicationGraphNode, EditorState } from "../src/types"; +import { startGraphEditorServer, type GraphEditorServer } from "./graphEditorServer"; + +test.describe.serial("PlantSimEngine model graph editor", () => { + let server: GraphEditorServer; + + test.beforeAll(async () => { + server = await startGraphEditorServer(); + }); + + test.afterAll(async () => { + await server?.stop(); + }); + + test("starts empty and adds an object", async ({ page, request }) => { + await page.goto(server.url); + await expect(page.getByText("Model Graph")).toBeVisible(); + + let state = await getState(request, server.url); + expect(state.ok).toBe(true); + expect(state.graph.metadata.objectCount).toBe(0); + expect(state.graph.metadata.applicationCount).toBe(0); + + await page.getByTestId("add-object").click(); + await page.getByTestId("object-id").fill("leaf"); + await page.locator(".object-form label", { hasText: "Scale" }).getByRole("textbox").fill("Leaf"); + await page.locator(".object-form label", { hasText: "Kind" }).getByRole("textbox").fill("organ"); + await page.locator(".object-form label", { hasText: "Name" }).getByRole("textbox").fill("leaf"); + await page.getByTestId("object-submit").click(); + + state = await waitForState(request, server.url, (value) => value.graph.metadata.objectCount === 1); + expect(state.graph.objects[0].scale).toBe("Leaf"); + }); + + test("adds and updates an application", async ({ page, request }) => { + await page.goto(server.url); + await openAddApplication(page, "Beer"); + await page.getByTestId("application-name").fill("light"); + await page.getByTestId("application-target-preview").click(); + await expect(page.getByTestId("application-target-preview-result")).toContainText("1 target object"); + await page.getByTestId("application-submit").click(); + + let state = await waitForState(request, server.url, (value) => value.graph.metadata.applicationCount === 1); + let light = findApplication(state, "light"); + expect(light.modelName).toContain("Beer"); + + await page.getByTestId("application-node-light").click(); + await page.getByRole("button", { name: "Edit application" }).click(); + await page.getByTestId("application-param-k").fill("0.8"); + await page.locator(".application-form label", { hasText: "Mode" }).getByRole("combobox").selectOption("clock"); + await page.locator(".application-form label", { hasText: "Step" }).getByRole("textbox").fill("2.0"); + await page.getByTestId("application-submit").click(); + + state = await waitForState(request, server.url, (value) => findApplication(value, "light").modelParameters.k?.value === 0.8); + light = findApplication(state, "light"); + expect(light.targetIds).toEqual(["leaf"]); + expect(String(light.timestep)).toContain("2.0"); + }); + + test("static viewer opens the inspector without an editor connection", async ({ page }) => { + const url = new URL(server.url); + url.pathname = "/static"; + await page.goto(url.toString()); + await expect(page.getByTestId("application-node-light")).toBeVisible(); + await page.getByTestId("application-node-light").click(); + await expect(page.locator(".model-inspector")).toContainText("light"); + await expect(page.getByText("Edit application")).toHaveCount(0); + }); + + test("creates and breaks a cycle directly in the graph", async ({ page, request }) => { + await page.goto(server.url); + await page.getByTestId("port-input-LAI").getByRole("button").click(); + await page.locator(".candidate-card", { hasText: "ReebE2E" }).click(); + await expect(page.getByTestId("application-form")).toBeVisible(); + await page.getByTestId("application-name").fill("reeb"); + await page.getByTestId("application-submit").click(); + + let state = await waitForState(request, server.url, (value) => value.graph.metadata.cyclic === true); + expect(state.graph.edges.filter((edge) => edge.cycle)).toHaveLength(2); + await expect(page.getByTestId("cycle-callout")).toBeVisible(); + + await page.getByTestId("choose-cycle-break").click(); + const scissors = page.locator("[data-testid^='cycle-break-']").first(); + await expect(scissors).toBeVisible(); + await scissors.click(); + await expect(page.getByTestId("cycle-break-dialog")).toBeVisible(); + const initialization = page.getByTestId("cycle-break-dialog").getByRole("textbox"); + if (await initialization.count()) await initialization.fill("0.0"); + await page.getByTestId("confirm-cycle-break").click(); + + state = await waitForState(request, server.url, (value) => value.graph.metadata.cyclic === false); + expect(state.graph.edges.some((edge) => edge.kind === "previous_timestep")).toBe(true); + expect(state.modelCode).toContain("PreviousTimeStep"); + }); + + test("connects another consumer and supports undo, redo, remove, and save", async ({ page, request }, testInfo) => { + await page.goto(server.url); + await openAddApplication(page, "E2EConsumer"); + await page.getByTestId("application-name").fill("consumer"); + await page.getByTestId("application-submit").click(); + await waitForState(request, server.url, (value) => value.graph.applications.some((application) => application.applicationId === "consumer")); + + await page.getByTestId("application-node-light").click(); + await page.getByTestId("configure-application").click(); + await page.getByTestId("call-name").fill("consumer_call"); + await page.getByTestId("call-target").selectOption("consumer"); + await page.getByTestId("add-call-binding").click(); + await waitForState(request, server.url, (value) => value.graph.metadata.callCount === 1); + await page.getByTestId("environment-provider").fill("model"); + await page.getByTestId("apply-environment-provider").click(); + let state = await waitForState(request, server.url, (value) => findApplication(value, "light").environment?.provider === "model"); + expect(state.graph.edges.some((edge) => edge.kind === "manual_call" && edge.call === "consumer_call")).toBe(true); + await page.getByRole("button", { name: "Done" }).click(); + + await page.getByTestId("port-output-aPPFD").first().getByRole("button").click(); + await page.locator(".candidate-card.existing", { hasText: "consumer" }).click(); + await expect(page.getByTestId("binding-form")).toBeVisible(); + await page.getByTestId("binding-preview-button").click(); + await expect(page.getByTestId("binding-preview")).toContainText("resolved binding"); + await page.getByTestId("binding-submit").click(); + state = await waitForState(request, server.url, (value) => value.graph.edges.some((edge) => edge.targetApplicationId === "consumer" && edge.targetVariable === "aPPFD")); + expect(state.graph.metadata.applicationCount).toBe(3); + + await page.getByTestId("application-node-light").click(); + await page.getByTestId("configure-application").click(); + await page.getByTitle("Remove consumer_call call").click(); + await waitForState(request, server.url, (value) => value.graph.metadata.callCount === 0); + await page.getByRole("button", { name: "Done" }).click(); + + await page.getByTestId("application-node-consumer").click(); + await page.getByRole("button", { name: "Remove application" }).click(); + await waitForState(request, server.url, (value) => !value.graph.applications.some((application) => application.applicationId === "consumer")); + await page.getByRole("button", { name: "Undo" }).click(); + await waitForState(request, server.url, (value) => value.graph.applications.some((application) => application.applicationId === "consumer")); + await page.getByRole("button", { name: "Redo" }).click(); + await waitForState(request, server.url, (value) => !value.graph.applications.some((application) => application.applicationId === "consumer")); + + const savePath = testInfo.outputPath("model.jl"); + await page.getByTestId("save-model").click(); + await page.getByPlaceholder("/absolute/path/to/model.jl").fill(savePath); + await page.getByRole("button", { name: "Save", exact: true }).click(); + state = await waitForState(request, server.url, (value) => value.savePath === savePath); + expect(state.recentPaths).toContain(savePath); + }); +}); + +async function openAddApplication(page: Page, modelName: string) { + await page.getByTestId("add-application").click(); + await selectOptionContaining(page.getByTestId("application-model-select"), modelName); +} + +async function getState(request: APIRequestContext, baseURL: string): Promise { + const response = await request.get(stateURL(baseURL)); + expect(response.ok()).toBe(true); + return await response.json() as EditorState; +} + +function stateURL(baseURL: string): string { + const url = new URL(baseURL); + const token = url.searchParams.get("token"); + url.pathname = "/state"; + url.search = ""; + if (token) url.searchParams.set("token", token); + return url.toString(); +} + +async function waitForState(request: APIRequestContext, baseURL: string, predicate: (state: EditorState) => boolean, timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs; + let latest = await getState(request, baseURL); + while (Date.now() < deadline) { + if (predicate(latest)) return latest; + await new Promise((resolve) => setTimeout(resolve, 200)); + latest = await getState(request, baseURL); + } + throw new Error(`Timed out waiting for editor state:\n${JSON.stringify(latest, null, 2)}`); +} + +function findApplication(state: EditorState, id: string): ApplicationGraphNode { + const application = state.graph.applications.find((item) => item.applicationId === id); + expect(application, `Expected application ${id}`).toBeTruthy(); + return application!; +} + +async function selectOptionContaining(select: Locator, text: string) { + const value = await select.evaluate((element, needle) => { + const selectElement = element as HTMLSelectElement; + return [...selectElement.options].find((option) => option.textContent?.includes(needle))?.value ?? null; + }, text); + expect(value, `Expected option containing ${text}`).toBeTruthy(); + await select.selectOption(value!); +} diff --git a/frontend/e2e/graphEditorServer.ts b/frontend/e2e/graphEditorServer.ts new file mode 100644 index 000000000..9fcbc708c --- /dev/null +++ b/frontend/e2e/graphEditorServer.ts @@ -0,0 +1,82 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(dirname, "../.."); +const serverScript = path.join(repoRoot, "test", "fixtures", "graph_editor_e2e_server.jl"); + +export type GraphEditorServer = { + url: string; + stop: () => Promise; +}; + +export async function startGraphEditorServer(): Promise { + if (process.env.PSE_GRAPH_EDITOR_URL) { + return { + url: process.env.PSE_GRAPH_EDITOR_URL, + stop: async () => {}, + }; + } + + const proc = spawn("julia", ["--project=test", "--startup-file=no", serverScript], { + cwd: repoRoot, + env: { ...process.env, JULIA_NUM_THREADS: process.env.JULIA_NUM_THREADS ?? "2" }, + }); + + let log = ""; + const url = await new Promise((resolve, reject) => { + let settled = false; + let timeout: ReturnType; + + const consume = (chunk: Buffer) => { + if (settled) return; + log += chunk.toString(); + const match = log.match(/PSE_GRAPH_EDITOR_URL=(http:\/\/127\.0\.0\.1:\d+[^\s]*)/); + if (match) { + settled = true; + clearTimeout(timeout); + resolve(match[1]); + } + }; + + const fail = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + void stopProcess(proc).finally(() => reject(error)); + }; + + timeout = setTimeout(() => { + fail(new Error(`Timed out waiting for graph editor URL.\n${log}`)); + }, 90_000); + + proc.stdout.on("data", consume); + proc.stderr.on("data", consume); + proc.once("exit", (code, signal) => { + fail(new Error(`Graph editor process exited before startup: code=${code} signal=${signal}\n${log}`)); + }); + proc.once("error", (error) => { + fail(error); + }); + }); + + return { + url, + stop: () => stopProcess(proc), + }; +} + +function stopProcess(proc: ChildProcessWithoutNullStreams): Promise { + if (proc.killed || proc.exitCode !== null) return Promise.resolve(); + return new Promise((resolve) => { + const killTimer = setTimeout(() => { + if (!proc.killed && proc.exitCode === null) proc.kill("SIGKILL"); + }, 3_000); + proc.once("exit", () => { + clearTimeout(killTimer); + resolve(); + }); + proc.kill("SIGTERM"); + }); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 000000000..0159e8aff --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + PlantSimEngine Dependency Graph + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 000000000..5fbe30597 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2618 @@ +{ + "name": "plantsimengine-graph-viewer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "plantsimengine-graph-viewer", + "version": "0.1.0", + "dependencies": { + "@xyflow/react": "^12.10.0", + "elkjs": "^0.11.0", + "lucide-react": "^0.468.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.8.0", + "vite": "^7.0.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", + "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "license": "EPL-2.0" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..c715dd081 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "plantsimengine-graph-viewer", + "private": true, + "version": "0.1.0", + "type": "module", + "packageManager": "npm@11.6.1", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run --passWithNoTests --exclude \"e2e/**\"", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --headed --debug" + }, + "dependencies": { + "@xyflow/react": "^12.10.0", + "elkjs": "^0.11.0", + "lucide-react": "^0.468.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.8.0", + "vite": "^7.0.0", + "vitest": "^3.0.0" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 000000000..532fc1152 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + reporter: process.env.CI ? [["dot"], ["html", { open: "never" }]] : "list", + use: { + baseURL: process.env.PSE_GRAPH_EDITOR_URL ?? "http://127.0.0.1:8765", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/frontend/src/App.test.ts b/frontend/src/App.test.ts new file mode 100644 index 000000000..46ebec9d9 --- /dev/null +++ b/frontend/src/App.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { applicationPortId, applicationsForPort, deriveCandidatePortIds, endpointsForCandidate, modelsForPort, objectSubtreeIds, selectorSuggestion } from "./App"; +import type { ApplicationGraphNode, GraphPort, ModelDescriptor, ObjectGraphNode, ModelGraphView } from "./types"; + +const output: GraphPort = { id: applicationPortId("source", "output", "signal"), name: "signal", role: "output", default: 0, defaultJulia: "0", expectedType: "Int" }; +const input: GraphPort = { id: applicationPortId("consumer", "input", "signal"), name: "signal", role: "input", default: 0, defaultJulia: "0", expectedType: "Int" }; + +const source = application("source", [], [output], ["leaf"]); +const consumer = application("consumer", [input], [], ["leaf"]); + +describe("candidate composition", () => { + it("keeps plus controls for exact existing-application matches", () => { + const graph = graphView([source, consumer], []); + const ids = deriveCandidatePortIds(graph); + expect(ids.has(output.id)).toBe(true); + expect(ids.has(input.id)).toBe(true); + }); + + it("matches model descriptors by exact declared variable name", () => { + const exact = model("Exact", { signal: 0 }, {}); + const near = model("Near", { Signal: 0 }, {}); + expect(modelsForPort([near, exact], output).map((item) => item.name)).toEqual(["Exact"]); + }); + + it("separates existing applications and produces directed binding endpoints", () => { + const candidate = { application: source, port: output, x: 0, y: 0 }; + expect(applicationsForPort([source, consumer], candidate)).toEqual([consumer]); + const endpoints = endpointsForCandidate(candidate, consumer); + expect(endpoints.sourceApplication.applicationId).toBe("source"); + expect(endpoints.targetApplication.applicationId).toBe("consumer"); + expect(endpoints.targetPort.name).toBe("signal"); + }); +}); + +describe("selector suggestions", () => { + it("suggests a conservative target selector from one application", () => { + const suggestion = selectorSuggestion(source); + expect(suggestion.multiplicity).toBe("one"); + expect(suggestion.criteria.scale).toBe("Leaf"); + }); +}); + +describe("object topology scoping", () => { + it("includes the selected object and all descendants", () => { + const objects: ObjectGraphNode[] = [ + object("plant", null), + object("leaf", "plant"), + object("cell", "leaf"), + object("soil", null), + ]; + expect(new Set(objectSubtreeIds(objects, "plant"))).toEqual(new Set(["plant", "leaf", "cell"])); + }); +}); + +function application(id: string, inputs: GraphPort[], outputs: GraphPort[], targetIds: unknown[]): ApplicationGraphNode { + return { + id: `application:${id}`, applicationId: id, name: id, process: id, modelType: id, modelName: id, module: "Main", package: null, + modelParameters: {}, selector: { type: "One", multiplicity: "one", criteria: { selectors: [], scale: "Leaf" }, julia: "" }, + targetIds, targetCount: targetIds.length, targetScales: ["Leaf"], targetKinds: [], targetSpecies: [], targetInstances: [], timestep: null, clock: null, + inputs, outputs, environmentInputs: [], environmentOutputs: [], inputBindings: {}, callBindings: {}, environment: null, meteoBindings: {}, meteoWindow: null, outputRouting: {}, updates: [], modelStorage: "shared_application", objectOverrides: [], + }; +} + +function model(name: string, inputs: Record, outputs: Record): ModelDescriptor { + return { type: name, name, module: "Main", package: null, process: name, processType: name, inputs, outputs, environmentInputs: {}, environmentOutputs: {}, constructor: { fields: [], parameterGroups: {}, hasZeroArgConstructor: true, constructible: true } }; +} + +function object(id: string, parent: string | null): ObjectGraphNode { + return { id: `object:${id}`, objectId: id, scale: null, kind: null, species: null, name: id, instance: null, parent, children: [], hasGeometry: false, hasStatus: false }; +} + +function graphView(applications: ApplicationGraphNode[], modelLibrary: ModelDescriptor[]): ModelGraphView { + return { + schemaVersion: 1, level: "applications", metadata: { title: "", modelRevision: 0, objectCount: 1, instanceCount: 0, applicationCount: applications.length, executionCount: applications.length, bindingCount: 0, callCount: 0, unresolvedInitializationCount: 0, cyclic: false, strictlyCompiled: true }, + objects: [], instances: [], applications, executions: [], edges: [], modelLibrary, initialization: [], diagnostics: [], cycles: [], availableActions: [], + }; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 000000000..b97fe91e5 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,1013 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Background, + Controls, + MarkerType, + MiniMap, + ReactFlow, + useEdgesState, + useNodesState, + type Connection, + type Edge, + type Node, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { + AlertTriangle, + Boxes, + CircleAlert, + Code2, + GitBranch, + FolderOpen, + Layers3, + Network, + Plus, + Scissors, + Search, + Save, + Undo2, + Redo2, + X, +} from "lucide-react"; +import { ApplicationForm, type ApplicationFormValue } from "./ApplicationForm"; +import { ApplicationConfigurationForm } from "./ApplicationConfigurationForm"; +import { BindingForm, type BindingEndpoints, type BindingFormValue } from "./BindingForm"; +import { ObjectForm, type ObjectFormValue } from "./ObjectForm"; +import { OverrideForm, type OverrideFormValue } from "./OverrideForm"; +import { ApplicationNode, EntityNode } from "./ModelNode"; +import { DependencyEdge } from "./DependencyEdge"; +import { layoutGraph, type LayoutMode } from "./layout"; +import { sampleModelGraph } from "./sampleModelGraph"; +import type { + ApplicationGraphNode, + DetailMode, + EditorState, + EnvironmentGraphNode, + ExecutionGraphNode, + GraphPort, + GraphViewMode, + InstanceDescriptor, + ModelDescriptor, + ObjectGraphNode, + RuntimeApplicationNode, + RuntimeEntityNode, + ModelGraphEdge, + ModelGraphView, + ModelRootDescriptor, + SelectorPreview, + TargetPreview, +} from "./types"; +import "./styles.css"; + +type FlowNode = Node; +type FlowEdge = Edge; +type CandidatePopover = { port: GraphPort; application: ApplicationGraphNode; x: number; y: number }; +type CycleBreakSelection = { application: ApplicationGraphNode; port: GraphPort }; +type InspectorSelection = ApplicationGraphNode | InstanceDescriptor | ObjectGraphNode | ExecutionGraphNode | EnvironmentGraphNode | ModelRootDescriptor | ModelGraphEdge | null; +type GraphScopeFilter = { label: string; objectIds: unknown[] }; +type ApplicationFormState = { + mode: "add" | "update"; + scope?: "application" | "template"; + instance?: string; + application?: ApplicationGraphNode; + initialModelType?: string; + suggestedSelector?: ApplicationGraphNode["selector"]; +}; +type ObjectFormState = { mode: "add" | "update"; object?: ObjectGraphNode }; + +const nodeTypes = { application: ApplicationNode, entity: EntityNode }; +const edgeTypes = { modelEdge: DependencyEdge }; + +export default function App() { + const [graph, setGraph] = useState(loadInitialGraph); + const [view, setView] = useState(() => loadInitialGraph().level); + const [detailMode, setDetailMode] = useState(() => loadInitialGraph().metadata.applicationCount > 24 ? "overview" : "detail"); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + const [scopeFilter, setScopeFilter] = useState(null); + const [selectedPort, setSelectedPort] = useState(null); + const [candidate, setCandidate] = useState(null); + const [showDiagnostics, setShowDiagnostics] = useState(false); + const [showInitialization, setShowInitialization] = useState(false); + const [showModelCode, setShowModelCode] = useState(false); + const [showOpen, setShowOpen] = useState(false); + const [showSave, setShowSave] = useState(false); + const [modelCode, setSceneCode] = useState(""); + const [autosavePath, setAutosavePath] = useState(null); + const [savePath, setSavePath] = useState(null); + const [recentPaths, setRecentPaths] = useState([]); + const [socket, setSocket] = useState(null); + const [connected, setConnected] = useState(false); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const [feedback, setFeedback] = useState(null); + const [applicationForm, setApplicationForm] = useState(null); + const [targetPreview, setTargetPreview] = useState(null); + const [objectForm, setObjectForm] = useState(null); + const [overrideApplication, setOverrideApplication] = useState(null); + const [configurationApplicationId, setConfigurationApplicationId] = useState(null); + const [bindingForm, setBindingForm] = useState(null); + const [bindingPreview, setBindingPreview] = useState(null); + const [cycleBreakMode, setCycleBreakMode] = useState(false); + const [cycleBreakSelection, setCycleBreakSelection] = useState(null); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + const editorConfig = useMemo(loadEditorConfig, []); + const applicationById = useMemo(() => new Map(graph.applications.map((application) => [application.applicationId, application])), [graph.applications]); + const unresolvedPortIds = useMemo(() => new Set( + graph.initialization + .filter((row) => row.role === "input" && row.disposition === "unresolved") + .map((row) => applicationPortId(row.applicationId, "input", row.variable)), + ), [graph.initialization]); + const previousPortIds = useMemo(() => new Set( + graph.initialization + .filter((row) => row.role === "input" && row.previousTimeStep) + .map((row) => applicationPortId(row.applicationId, "input", row.variable)), + ), [graph.initialization]); + const cyclicApplications = useMemo(() => new Set(graph.cycles.flatMap((cycle) => cycle.applicationIds)), [graph.cycles]); + const cycleBreakPortIds = useMemo(() => new Set( + graph.cycles.flatMap((cycle) => cycle.breakCandidates.map((candidate) => applicationPortId(candidate.applicationId, "input", candidate.input))), + ), [graph.cycles]); + const candidatePortIds = useMemo(() => deriveCandidatePortIds(graph), [graph]); + const candidateModels = useMemo(() => candidate ? modelsForPort(graph.modelLibrary, candidate.port) : [], [candidate, graph.modelLibrary]); + const candidateApplications = useMemo(() => candidate ? applicationsForPort(graph.applications, candidate) : [], [candidate, graph.applications]); + const portIndex = useMemo(() => { + const index = new Map(); + for (const application of graph.applications) { + for (const port of [...application.inputs, ...application.outputs]) index.set(port.id, { application, port }); + } + return index; + }, [graph.applications]); + const scopedObjectIds = useMemo( + () => scopeFilter ? new Set(scopeFilter.objectIds.map(objectKey)) : null, + [scopeFilter], + ); + + useEffect(() => { + if (!editorConfig?.websocketUrl) return; + const nextSocket = new WebSocket(editorConfig.websocketUrl); + setSocket(nextSocket); + nextSocket.addEventListener("open", () => { setConnected(true); setFeedback(null); }); + nextSocket.addEventListener("close", () => { setConnected(false); setFeedback("Editor connection closed."); }); + nextSocket.addEventListener("message", (event) => { + const payload = JSON.parse(event.data) as EditorState; + if (payload.graph) setGraph(payload.graph); + if (typeof payload.modelCode === "string") setSceneCode(payload.modelCode); + setAutosavePath(payload.autosavePath ?? null); + setSavePath(payload.savePath ?? null); + setRecentPaths(payload.recentPaths ?? []); + if (payload.selectorPreview) setBindingPreview(payload.selectorPreview); + if (payload.targetPreview) setTargetPreview(payload.targetPreview); + if (payload.ok === false) { + setBindingPreview(null); + setTargetPreview(null); + } + setCanUndo(Boolean(payload.canUndo)); + setCanRedo(Boolean(payload.canRedo)); + setFeedback(payload.ok === false ? payload.diagnostics?.[0] || "The edit failed." : null); + }); + return () => nextSocket.close(); + }, [editorConfig?.websocketUrl]); + + useEffect(() => { + if (!graph.metadata.cyclic) { + setCycleBreakMode(false); + setCycleBreakSelection(null); + } + }, [graph.metadata.cyclic]); + + const sendCommand = useCallback((command: Record) => { + if (!socket || socket.readyState !== WebSocket.OPEN) { + setFeedback("This action requires an interactive Julia editor session."); + return; + } + socket.send(JSON.stringify(command)); + }, [socket]); + + const openCandidates = useCallback((application: ApplicationGraphNode, port: GraphPort, anchor: { x: number; y: number }) => { + setSelected(application); + setSelectedPort(port); + setCandidate({ application, port, x: anchor.x, y: anchor.y }); + }, []); + + useEffect(() => { + const nextNodes = buildNodes({ + graph, + view, + detailMode, + query, + scopedObjectIds, + unresolvedPortIds, + previousPortIds, + candidatePortIds, + cyclicApplications, + cycleBreakPortIds, + cycleBreakMode, + openCandidates, + onPortClick: setSelectedPort, + onCycleBreak: (application, port) => setCycleBreakSelection({ application, port }), + }); + const nodeIds = new Set(nextNodes.map((node) => node.id)); + const nextEdges = buildEdges(graph, view).filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target)); + const layoutMode: LayoutMode = view === "topology" ? "topology" : detailMode === "overview" ? "overview" : "data_flow"; + layoutGraph(nextNodes, nextEdges, layoutMode).then(setNodes); + setEdges(nextEdges); + }, [candidatePortIds, cycleBreakMode, cycleBreakPortIds, cyclicApplications, detailMode, graph, openCandidates, previousPortIds, query, scopedObjectIds, setEdges, setNodes, unresolvedPortIds, view]); + + const inspectSelection = useCallback((_: unknown, node: FlowNode) => { + if (node.data.nodeKind === "application") { + setSelected(applicationById.get(node.data.applicationId) ?? null); + } else { + setSelected(node.data.detail); + if (node.data.nodeKind === "object") { + const object = node.data.detail as ObjectGraphNode; + setScopeFilter({ + label: `subtree ${object.name || String(object.objectId)}`, + objectIds: objectSubtreeIds(graph.objects, object.objectId), + }); + } else if (node.data.nodeKind === "instance") { + const instance = node.data.detail as InstanceDescriptor; + setScopeFilter({ label: `instance ${instance.name}`, objectIds: instance.objectIds }); + } else if (node.data.nodeKind === "model") { + setScopeFilter(null); + } + } + setSelectedPort(null); + }, [applicationById, graph.objects]); + + const selectCandidateModel = useCallback((model: ModelDescriptor) => { + if (!candidate) return; + setTargetPreview(null); + setApplicationForm({ + mode: "add", + initialModelType: model.type, + suggestedSelector: selectorSuggestion(candidate.application), + }); + if (!connected) setFeedback(`${model.name} matches ${candidate.port.name}. Start an interactive Julia editor session to add it to the composite model.`); + setCandidate(null); + }, [candidate, connected]); + + const submitApplication = useCallback((value: ApplicationFormValue) => { + if (!connected) { + setFeedback("Adding or updating an application requires an interactive Julia editor session."); + setApplicationForm(null); + return; + } + sendCommand({ + action: "edit", + kind: value.applicationId ? applicationForm?.scope === "template" ? "update_template_application" : "update_application" : "add_application", + instance: applicationForm?.instance, + ...value, + }); + setApplicationForm(null); + }, [applicationForm?.instance, applicationForm?.scope, connected, sendCommand]); + + const submitBinding = useCallback((value: BindingFormValue) => { + if (!connected) { + setFeedback("Creating a binding requires an interactive Julia editor session."); + setBindingForm(null); + return; + } + sendCommand({ action: "edit", kind: "set_input_binding", ...value }); + setBindingForm(null); + }, [connected, sendCommand]); + + const submitObject = useCallback((value: ObjectFormValue) => { + if (!connected) { + setFeedback("Adding or updating an object requires an interactive Julia editor session."); + setObjectForm(null); + return; + } + sendCommand({ + action: "edit", + kind: objectForm?.mode === "update" ? "update_object" : "add_object", + objectId: value.objectId, + configuration: value.configuration, + }); + setObjectForm(null); + }, [connected, objectForm?.mode, sendCommand]); + + const submitOverride = useCallback((value: OverrideFormValue) => { + if (!connected) { + setFeedback("Creating an override requires an interactive Julia editor session."); + setOverrideApplication(null); + return; + } + sendCommand({ + action: "edit", + kind: value.scope === "instance" ? "set_instance_override" : "set_object_override", + ...value, + }); + setOverrideApplication(null); + }, [connected, sendCommand]); + + const removeOverride = useCallback((value: OverrideFormValue) => { + if (!connected) { + setFeedback("Removing an override requires an interactive Julia editor session."); + return; + } + sendCommand({ + action: "edit", + kind: value.scope === "instance" ? "remove_instance_override" : "remove_object_override", + ...value, + }); + setOverrideApplication(null); + }, [connected, sendCommand]); + + const connectPorts = useCallback((connection: Connection) => { + if (!connection.sourceHandle || !connection.targetHandle) return; + const source = portIndex.get(connection.sourceHandle); + const target = portIndex.get(connection.targetHandle); + if (!source || !target || source.port.role !== "output" || target.port.role !== "input") { + setFeedback("Connect an application output to an application input."); + return; + } + setBindingForm({ + sourceApplication: source.application, + sourcePort: source.port, + targetApplication: target.application, + targetPort: target.port, + }); + setBindingPreview(null); + }, [portIndex]); + + const activeInitialization = useMemo(() => { + if (!selected) return graph.initialization; + if ("applicationId" in selected) return graph.initialization.filter((row) => row.applicationId === selected.applicationId); + if ("objectId" in selected) return graph.initialization.filter((row) => String(row.objectId) === String(selected.objectId)); + if ("objectIds" in selected) { + const ids = new Set(selected.objectIds.map(objectKey)); + return graph.initialization.filter((row) => ids.has(objectKey(row.objectId))); + } + return graph.initialization; + }, [graph.initialization, selected]); + + return ( +
+
+
+ +
PLANTSIMENGINEModel Graph
+
+
+ + setQuery(event.target.value)} placeholder="Search application, object, or variable" /> + {query && } +
+
+ {graph.metadata.applicationCount} applications + {graph.metadata.objectCount} objects + {graph.metadata.unresolvedInitializationCount > 0 && ( + + )} + {graph.diagnostics.length > 0 && ( + + )} +
+ +
+ {editorConfig && } + {editorConfig && } + {view !== "topology" && ( + + )} + {editorConfig && } + {editorConfig && } + {editorConfig && } + {editorConfig && } + +
+
+ + {graph.metadata.cyclic && ( +
+ +
Current-step dependency cycleSelect a cycle input to read its previous accepted timestep value.
+ +
+ )} + {feedback &&
{feedback}
} + {scopeFilter && ( +
+ Showing {view === "resolved" ? "executions" : view === "applications" ? "applications" : "topology"} for {scopeFilter.label} ({scopeFilter.objectIds.length} objects) + {view === "topology" && } + +
+ )} + +
+
+ setSelected(edge.data ?? null)} + fitView + minZoom={0.05} + maxZoom={2} + > + + + + +
+ { + setTargetPreview(null); + setApplicationForm({ + mode: "update", + scope: application.targetInstances.length > 0 ? "template" : "application", + instance: application.targetInstances[0], + application, + }); + }} + onRemoveApplication={(application) => sendCommand({ + action: "edit", + kind: application.targetInstances.length > 0 ? "remove_template_application" : "remove_application", + instance: application.targetInstances[0], + applicationId: application.applicationId, + })} + onConfigureApplication={(application) => setConfigurationApplicationId(application.applicationId)} + onOverrideApplication={setOverrideApplication} + onEditObject={(object) => setObjectForm({ mode: "update", object })} + onRemoveObject={(object) => sendCommand({ action: "edit", kind: "remove_object", objectId: object.objectId, recursive: true })} + /> +
+ + {candidate && (candidateModels.length > 0 || candidateApplications.length > 0) && ( + { + setBindingForm(endpointsForCandidate(candidate, application)); + setBindingPreview(null); + setCandidate(null); + }} + onClose={() => setCandidate(null)} + /> + )} + {showDiagnostics && setShowDiagnostics(false)} sendCommand={sendCommand} interactive={connected} />} + {showInitialization && setShowInitialization(false)} sendCommand={sendCommand} interactive={connected} />} + {showModelCode && setShowModelCode(false)} />} + {showOpen && { sendCommand({ action: "open_model_code", path }); setShowOpen(false); }} onClose={() => setShowOpen(false)} />} + {showSave && { sendCommand({ action: "save_model_code", path }); setShowSave(false); }} onClose={() => setShowSave(false)} />} + {applicationForm && ( + { setTargetPreview(null); sendCommand({ action: "preview_application_targets", selector }); }} + onSubmit={submitApplication} + onClose={() => { setApplicationForm(null); setTargetPreview(null); }} + /> + )} + {bindingForm && ( + { setBindingPreview(null); sendCommand({ action: "preview_input_binding", ...value }); }} + onSubmit={submitBinding} + onClose={() => { setBindingForm(null); setBindingPreview(null); }} + /> + )} + {objectForm && ( + setObjectForm(null)} /> + )} + {overrideApplication && ( + setOverrideApplication(null)} /> + )} + {configurationApplicationId && applicationById.get(configurationApplicationId) && ( + setConfigurationApplicationId(null)} /> + )} + {cycleBreakSelection && ( + { + sendCommand({ + action: "edit", + kind: "break_cycle", + applicationId: cycleBreakSelection.application.applicationId, + input: cycleBreakSelection.port.name, + initializeMissing, + initialValue, + }); + setCycleBreakSelection(null); + }} + onClose={() => setCycleBreakSelection(null)} + /> + )} +
+ ); +} + +function buildNodes({ + graph, + view, + detailMode, + query, + scopedObjectIds, + unresolvedPortIds, + previousPortIds, + candidatePortIds, + cyclicApplications, + cycleBreakPortIds, + cycleBreakMode, + openCandidates, + onPortClick, + onCycleBreak, +}: { + graph: ModelGraphView; + view: GraphViewMode; + detailMode: DetailMode; + query: string; + scopedObjectIds: Set | null; + unresolvedPortIds: Set; + previousPortIds: Set; + candidatePortIds: Set; + cyclicApplications: Set; + cycleBreakPortIds: Set; + cycleBreakMode: boolean; + openCandidates: (application: ApplicationGraphNode, port: GraphPort, anchor: { x: number; y: number }) => void; + onPortClick: (port: GraphPort) => void; + onCycleBreak: (application: ApplicationGraphNode, port: GraphPort) => void; +}): FlowNode[] { + const matches = (value: unknown) => !query || JSON.stringify(value).toLowerCase().includes(query.toLowerCase()); + if (view === "topology") { + const modelDetail: ModelRootDescriptor = { + entity: "model", + objectCount: graph.metadata.objectCount, + instanceCount: graph.metadata.instanceCount, + applicationCount: graph.metadata.applicationCount, + }; + const modelNode: FlowNode = { + id: "model:root", + type: "entity", + position: { x: 0, y: 0 }, + data: { + nodeKind: "model", + title: graph.metadata.title || "Composite model", + subtitle: "model root", + badges: [`${graph.metadata.instanceCount} instances`, `${graph.metadata.objectCount} objects`], + detail: modelDetail, + }, + }; + const instanceNodes: FlowNode[] = graph.instances.filter(matches).map((instance) => ({ + id: instance.id, + type: "entity", + position: { x: 0, y: 0 }, + data: { + nodeKind: "instance", + title: instance.name, + subtitle: [instance.kind, instance.species].filter(Boolean).join(" · ") || "object instance", + badges: [`${instance.objectIds.length} objects`, `${instance.applicationIds.length} applications`, `${instance.instanceOverrides.length + instance.objectOverrides.length} overrides`], + detail: instance, + }, + })); + const objectNodes: FlowNode[] = graph.objects.filter(matches).map((object) => ({ + id: object.id, + type: "entity", + position: { x: 0, y: 0 }, + data: { + nodeKind: "object", + title: object.name || String(object.objectId), + subtitle: [object.kind, object.scale, object.instance].filter(Boolean).join(" · "), + badges: [object.species, object.hasStatus ? "status" : null, object.hasGeometry ? "geometry" : null].filter(Boolean) as string[], + detail: object, + }, + })); + return [modelNode, ...instanceNodes, ...objectNodes]; + } + if (view === "resolved") { + const applications = new Map(graph.applications.map((application) => [application.applicationId, application])); + const executionNodes: FlowNode[] = graph.executions + .filter((execution) => !scopedObjectIds || scopedObjectIds.has(objectKey(execution.objectId))) + .filter(matches).map((execution) => { + const application = applications.get(execution.applicationId); + return { + id: execution.id, + type: "entity", + position: { x: 0, y: 0 }, + data: { + nodeKind: "execution", + title: execution.applicationId, + subtitle: `object ${String(execution.objectId)}`, + badges: [shortType(execution.modelType), execution.overridden ? "override" : "shared"], + inputPortIds: [...(application?.inputs ?? []), ...(application?.environmentInputs ?? [])].map((port) => port.id), + outputPortIds: [...(application?.outputs ?? []), ...(application?.environmentOutputs ?? [])].map((port) => port.id), + detail: execution, + }, + }; + }); + return [...executionNodes, ...environmentNodes(graph, "resolved")]; + } + const applicationNodes: FlowNode[] = graph.applications + .filter((application) => !scopedObjectIds || application.targetIds.some((id) => scopedObjectIds.has(objectKey(id)))) + .filter(matches).map((application) => ({ + id: application.id, + type: "application", + position: { x: 0, y: 0 }, + data: { + ...application, + nodeKind: "application", + detailMode, + cyclic: cyclicApplications.has(application.applicationId), + requiredInputPortIds: application.inputs.filter((port) => unresolvedPortIds.has(port.id)).map((port) => port.id), + candidatePortIds: [...application.inputs, ...application.outputs].filter((port) => candidatePortIds.has(port.id)).map((port) => port.id), + previousTimeStepPortIds: application.inputs.filter((port) => previousPortIds.has(port.id)).map((port) => port.id), + cycleBreakInputPortIds: application.inputs.filter((port) => cycleBreakPortIds.has(port.id)).map((port) => port.id), + cycleBreakMode, + onCandidateClick: (port, anchor) => openCandidates(application, port, anchor), + onPortClick, + onCycleBreak, + }, + })); + return [...applicationNodes, ...environmentNodes(graph, "applications")]; +} + +function environmentNodes(graph: ModelGraphView, projection: "applications" | "resolved"): FlowNode[] { + const relevant = graph.edges.filter((edge) => edge.kind === "environment_binding" && edge.projection === projection); + const ids = new Set(relevant.flatMap((edge) => [edge.source, edge.target]).filter((id) => id.startsWith("environment:"))); + return [...ids].map((id) => { + const provider = id.slice("environment:".length); + const inputs = uniqueStrings(relevant.filter((edge) => edge.target === id).map((edge) => edge.targetPort).filter(Boolean) as string[]); + const outputs = uniqueStrings(relevant.filter((edge) => edge.source === id).map((edge) => edge.sourcePort).filter(Boolean) as string[]); + return { + id, + type: "entity", + position: { x: 0, y: 0 }, + data: { + nodeKind: "environment", + title: provider, + subtitle: "environment provider", + badges: [`${outputs.length} inputs`, `${inputs.length} outputs`], + inputPortIds: inputs, + outputPortIds: outputs, + detail: { provider }, + }, + }; + }); +} + +function uniqueStrings(values: string[]) { return [...new Set(values)]; } + +function buildEdges(graph: ModelGraphView, view: GraphViewMode): FlowEdge[] { + const sourceEdges = view === "topology" ? [...graph.edges, ...topologyContainerEdges(graph)] : graph.edges; + return sourceEdges + .filter((edge) => edgeProjectionMatches(edge, view)) + .map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourcePort || undefined, + targetHandle: edge.targetPort || undefined, + type: "modelEdge", + data: edge, + markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor(edge), width: 16, height: 16 }, + style: { + stroke: edgeColor(edge), + strokeWidth: edge.cycle ? 4 : edge.kind === "manual_call" ? 2.5 : 1.8, + strokeDasharray: edge.kind === "previous_timestep" ? "7 5" : edge.kind === "manual_call" ? "3 4" : undefined, + }, + })); +} + +function topologyContainerEdges(graph: ModelGraphView): ModelGraphEdge[] { + const edges: ModelGraphEdge[] = []; + const instanceObjectIds = new Set(graph.instances.flatMap((instance) => instance.objectIds.map(objectKey))); + for (const instance of graph.instances) { + edges.push({ + id: `topology:model:${instance.id}`, + source: "model:root", + target: instance.id, + kind: "object_topology", + projection: "topology", + cycle: false, + }); + edges.push({ + id: `topology:${instance.id}:object:${String(instance.rootId)}`, + source: instance.id, + target: `object:${String(instance.rootId)}`, + kind: "object_topology", + projection: "topology", + cycle: false, + }); + } + for (const object of graph.objects) { + if (object.parent === null && !instanceObjectIds.has(objectKey(object.objectId))) { + edges.push({ + id: `topology:model:${object.id}`, + source: "model:root", + target: object.id, + kind: "object_topology", + projection: "topology", + cycle: false, + }); + } + } + return edges; +} + +export function objectSubtreeIds(objects: ObjectGraphNode[], rootId: unknown): unknown[] { + const children = new Map(); + for (const object of objects) { + if (object.parent === null) continue; + const key = objectKey(object.parent); + children.set(key, [...(children.get(key) ?? []), object.objectId]); + } + const result: unknown[] = []; + const pending: unknown[] = [rootId]; + const visited = new Set(); + while (pending.length > 0) { + const id = pending.pop()!; + const key = objectKey(id); + if (visited.has(key)) continue; + visited.add(key); + result.push(id); + pending.push(...(children.get(key) ?? [])); + } + return result; +} + +function objectKey(value: unknown) { return String(value); } + +function edgeProjectionMatches(edge: ModelGraphEdge, view: GraphViewMode) { + const projection = (edge as ModelGraphEdge & { projection?: string }).projection; + if (view === "topology") return edge.kind === "object_topology"; + if (view === "resolved") return projection === "resolved"; + return projection === "applications" || (!projection && !["object_topology", "application_target"].includes(edge.kind)); +} + +function edgeColor(edge: ModelGraphEdge) { + if (edge.cycle) return "#cf4937"; + if (edge.kind === "previous_timestep") return "#317b62"; + if (edge.kind === "manual_call") return "#be6a54"; + if (edge.kind === "object_topology") return "#7b7167"; + if (edge.kind === "environment_binding") return "#367b8b"; + return "#a59687"; +} + +export function deriveCandidatePortIds(graph: ModelGraphView) { + const result = new Set(); + for (const application of graph.applications) { + for (const input of application.inputs) { + const existing = graph.applications.some((other) => + other.applicationId !== application.applicationId && other.outputs.some((output) => output.name === input.name) + ); + if (existing || graph.modelLibrary.some((model) => Object.prototype.hasOwnProperty.call(model.outputs, input.name))) result.add(input.id); + } + for (const output of application.outputs) { + const existing = graph.applications.some((other) => + other.applicationId !== application.applicationId && other.inputs.some((input) => input.name === output.name) + ); + if (existing || graph.modelLibrary.some((model) => Object.prototype.hasOwnProperty.call(model.inputs, output.name))) result.add(output.id); + } + } + return result; +} + +export function modelsForPort(library: ModelDescriptor[], port: GraphPort) { + const field = port.role === "input" ? "outputs" : "inputs"; + return library + .filter((model) => Object.prototype.hasOwnProperty.call(model[field], port.name)) + .sort((left, right) => `${left.package}.${left.name}`.localeCompare(`${right.package}.${right.name}`)); +} + +function CandidatePopover({ + candidate, + models, + applications, + onSelectModel, + onSelectApplication, + onClose, +}: { + candidate: CandidatePopover; + models: ModelDescriptor[]; + applications: ApplicationGraphNode[]; + onSelectModel: (model: ModelDescriptor) => void; + onSelectApplication: (application: ApplicationGraphNode) => void; + onClose: () => void; +}) { + const title = candidate.port.role === "input" ? `Models that compute ${candidate.port.name}` : `Models that consume ${candidate.port.name}`; + return ( +
+
{title}Exact declared variable-name matches
+
+ {applications.length > 0 &&
Existing applications
} + {applications.map((application) => ( + + ))} + {models.length > 0 &&
Available models
} + {models.map((model) => ( + + ))} +
+
+ ); +} + +export function applicationsForPort(applications: ApplicationGraphNode[], candidate: CandidatePopover) { + return applications + .filter((application) => application.applicationId !== candidate.application.applicationId) + .filter((application) => { + const ports = candidate.port.role === "input" ? application.outputs : application.inputs; + return ports.some((port) => port.name === candidate.port.name); + }) + .sort((left, right) => left.applicationId.localeCompare(right.applicationId)); +} + +export function endpointsForCandidate(candidate: CandidatePopover, application: ApplicationGraphNode): BindingEndpoints { + if (candidate.port.role === "input") { + const sourcePort = application.outputs.find((port) => port.name === candidate.port.name); + if (!sourcePort) throw new Error(`Application ${application.applicationId} does not output ${candidate.port.name}.`); + return { sourceApplication: application, sourcePort, targetApplication: candidate.application, targetPort: candidate.port }; + } + const targetPort = application.inputs.find((port) => port.name === candidate.port.name); + if (!targetPort) throw new Error(`Application ${application.applicationId} does not input ${candidate.port.name}.`); + return { sourceApplication: candidate.application, sourcePort: candidate.port, targetApplication: application, targetPort }; +} + +function Inspector({ selection, port, initialization, interactive, onEditApplication, onConfigureApplication, onRemoveApplication, onOverrideApplication, onEditObject, onRemoveObject }: { selection: InspectorSelection; port: GraphPort | null; initialization: ModelGraphView["initialization"]; interactive: boolean; onEditApplication: (application: ApplicationGraphNode) => void; onConfigureApplication: (application: ApplicationGraphNode) => void; onRemoveApplication: (application: ApplicationGraphNode) => void; onOverrideApplication: (application: ApplicationGraphNode) => void; onEditObject: (object: ObjectGraphNode) => void; onRemoveObject: (object: ObjectGraphNode) => void }) { + const application = selection && "applicationId" in selection && "selector" in selection ? selection as ApplicationGraphNode : null; + const object = selection && "objectId" in selection && !("applicationId" in selection) ? selection as ObjectGraphNode : null; + return ( + + ); +} + +function DiagnosticsPanel({ graph, onClose, sendCommand, interactive }: { graph: ModelGraphView; onClose: () => void; sendCommand: (command: Record) => void; interactive: boolean }) { + return + {graph.diagnostics.map((diagnostic) =>
{diagnostic.code}

{diagnostic.message}

{diagnostic.suggestions.map((suggestion) => {suggestion})}
)} + {graph.cycles.map((cycle) =>
{cycle.applicationIds.join(" → ")}

Choose an input to read from the previous timestep.

{cycle.breakCandidates.map((candidate) => )}
)} + {graph.diagnostics.length === 0 && graph.cycles.length === 0 &&

No diagnostics.

} +
; +} + +function InitializationPanel({ graph, onClose, sendCommand, interactive }: { graph: ModelGraphView; onClose: () => void; sendCommand: (command: Record) => void; interactive: boolean }) { + const unresolved = graph.initialization.filter((row) => row.disposition === "unresolved"); + const groups = new Map(); + for (const row of unresolved) { + const key = `${row.applicationId}:${row.variable}`; + groups.set(key, [...(groups.get(key) || []), row]); + } + return + {[...groups.entries()].map(([key, rows]) => )} + {unresolved.length === 0 &&

No unresolved initial values.

} +
; +} + +function InitializationGroup({ rows, interactive, sendCommand }: { rows: ModelGraphView["initialization"]; interactive: boolean; sendCommand: (command: Record) => void }) { + const [valueType, setValueType] = useState("float"); + const [value, setValue] = useState(""); + const first = rows[0]; + const typedValue = { type: valueType, value }; + return
+
{first.variable}{first.applicationId}
{rows.length} object{rows.length === 1 ? "" : "s"} · expected {first.expectedType}
+

Required because the input has no producer, environment source, status value, or usable temporal initialization.

+ {interactive &&
} +
{rows.map((row) =>
Object {String(row.objectId)}{row.origin}{interactive && }
)}
+
; +} + +function SceneCodePanel({ code, onClose }: { code: string; onClose: () => void }) { + return
{code || "Model code is available from an interactive editor session."}
; +} + +function SceneFileDialog({ mode, recentPaths, currentPath, autosavePath, onSubmit, onClose }: { mode: "open" | "save"; recentPaths: string[]; currentPath: string | null; autosavePath: string | null; onSubmit: (path: string) => void; onClose: () => void }) { + const [path, setPath] = useState(currentPath || ""); + return +
+

{mode === "open" ? "Open a Julia script whose final binding is `model = CompositeModel(...)`. Future edits will be saved back to that file." : "After the first save, every successful graph edit automatically rewrites this Julia script."}

+ + {mode === "open" && recentPaths.length > 0 &&
Recent models
{recentPaths.map((recent) => )}
} + {mode === "open" && autosavePath &&
Recovery autosave
} + Use Git to version saved composite-model scripts and review scientific configuration changes. +
+
; +} + +function CycleBreakDialog({ + selection, + initialization, + onSubmit, + onClose, +}: { + selection: CycleBreakSelection; + initialization: ModelGraphView["initialization"]; + onSubmit: (initializeMissing: boolean, initialValue: { type: string; value: string } | null) => void; + onClose: () => void; +}) { + const missing = initialization.filter((row) => + row.applicationId === selection.application.applicationId && + row.variable === selection.port.name && + row.disposition !== "supplied" + ); + const [valueType, setValueType] = useState("float"); + const [value, setValue] = useState(""); + return
+
event.stopPropagation()} data-testid="cycle-break-dialog"> +
Break the current-step cycle{selection.application.applicationId}.{selection.port.name}
+
+

This changes the application input to read its value from the previous accepted timestep. The model is disconnected from the current value during each run step.

+
Application-wide changeIt affects all {selection.application.targetCount} targets selected by this application.
+ {missing.length > 0 &&
Required initial value

{missing.length} target{missing.length === 1 ? "" : "s"} need a value before the first timestep.

} +
+
+
+
; +} + +function Overlay({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { + return
event.stopPropagation()}>
{title}
{children}
; +} + +function selectionLabel(selection: Exclude) { + if ("applicationId" in selection) return selection.applicationId; + if ("objectId" in selection) return String(selection.objectId); + if ("objectIds" in selection) return selection.name; + if ("entity" in selection) return "Composite model"; + if ("provider" in selection) return selection.provider; + return selection.kind.replaceAll("_", " "); +} + +function shortType(type: string) { + return type.split(".").at(-1) || type; +} + +export function selectorSuggestion(application: ApplicationGraphNode): ApplicationGraphNode["selector"] { + const criteria: Record = { selectors: [] }; + if (application.targetInstances.length === 0 && application.targetScales.length === 1) criteria.scale = application.targetScales[0]; + if (application.targetKinds.length === 1) criteria.kind = application.targetKinds[0]; + if (application.targetSpecies.length === 1) criteria.species = application.targetSpecies[0]; + return { type: application.targetCount === 1 ? "One" : "Many", multiplicity: application.targetCount === 1 ? "one" : "many", criteria, julia: "" }; +} + +export function applicationPortId(applicationId: string, role: "input" | "output", variable: string) { + return `application:${applicationId}:${role}:${variable}`; +} + +function loadInitialGraph(): ModelGraphView { + const element = document.getElementById("pse-model-graph-data"); + if (!element?.textContent) return sampleModelGraph; + try { return JSON.parse(element.textContent) as ModelGraphView; } catch { return sampleModelGraph; } +} + +function loadEditorConfig(): { websocketUrl: string } | null { + const element = document.getElementById("pse-editor-config"); + if (!element?.textContent) return null; + try { return JSON.parse(element.textContent) as { websocketUrl: string }; } catch { return null; } +} diff --git a/frontend/src/ApplicationConfigurationForm.tsx b/frontend/src/ApplicationConfigurationForm.tsx new file mode 100644 index 000000000..9300d06c0 --- /dev/null +++ b/frontend/src/ApplicationConfigurationForm.tsx @@ -0,0 +1,88 @@ +import { useMemo, useState } from "react"; +import { Check, Plus, Trash2, X } from "lucide-react"; +import type { ApplicationGraphNode, SelectorDescriptor } from "./types"; + +type UpdateRule = { variables: string[]; after: string[] }; + +export function ApplicationConfigurationForm({ + application, + applications, + onCommand, + onClose, +}: { + application: ApplicationGraphNode; + applications: ApplicationGraphNode[]; + onCommand: (command: Record) => void; + onClose: () => void; +}) { + const otherApplications = applications.filter((item) => item.applicationId !== application.applicationId); + const [callName, setCallName] = useState(""); + const [calleeId, setCalleeId] = useState(otherApplications[0]?.applicationId || ""); + const [provider, setProvider] = useState(String(application.environment?.provider || "scene")); + const [updateRules, setUpdateRules] = useState(application.updates || []); + const [updateVariable, setUpdateVariable] = useState(application.outputs[0]?.name || ""); + const [updateAfter, setUpdateAfter] = useState(otherApplications[0]?.applicationId || ""); + const selectedCallee = otherApplications.find((item) => item.applicationId === calleeId); + const callSelector = useMemo(() => ({ + type: selectedCallee?.targetCount === 1 ? "One" : "Many", + multiplicity: selectedCallee?.targetCount === 1 ? "one" : "many", + criteria: { + selectors: [], + within: { type: "SceneScope" }, + application: calleeId, + }, + julia: "", + }), [calleeId, selectedCallee?.targetCount]); + + const addCall = () => { + if (!callName.trim() || !calleeId) return; + onCommand({ + action: "edit", + kind: "set_call_binding", + applicationId: application.applicationId, + call: callName.trim(), + selector: callSelector, + }); + setCallName(""); + }; + + const addUpdateRule = () => { + if (!updateVariable || !updateAfter) return; + setUpdateRules((current) => [ + ...current.filter((rule) => !rule.variables.includes(updateVariable)), + { variables: [updateVariable], after: [updateAfter] }, + ]); + }; + + return
+
event.stopPropagation()} data-testid="application-configuration-form"> +
Configure {application.applicationId}Authored coupling and execution policy, validated by Julia
+
+
Explicit input bindings + {Object.entries(application.inputBindings).length === 0 &&

No authored input bindings. Unique same-object producers may still be inferred.

} +
{Object.entries(application.inputBindings).map(([input, selector]) =>
{input}{selector.julia || selector.type}
)}
+
+ +
Manual calls +
{Object.entries(application.callBindings).map(([call, selector]) =>
{call}{selector.julia || selector.type}
)}
+
+
+ +
Environment +
+ {Object.keys(application.meteoBindings || {}).length > 0 && {JSON.stringify(application.meteoBindings)}} +
+ +
Output routing +
{application.outputs.map((output) => )}
+
+ +
Duplicate-writer ordering +
{updateRules.map((rule, index) =>
{rule.variables.join(", ")}after {rule.after.join(", ")}
)}
+
+
+
+
+
+
; +} diff --git a/frontend/src/ApplicationForm.tsx b/frontend/src/ApplicationForm.tsx new file mode 100644 index 000000000..8e86e0a73 --- /dev/null +++ b/frontend/src/ApplicationForm.tsx @@ -0,0 +1,163 @@ +import { useEffect, useMemo, useState } from "react"; +import { Check, Eye, X } from "lucide-react"; +import type { ApplicationGraphNode, ModelConstructorField, ModelDescriptor, ObjectGraphNode, SelectorDescriptor, TargetPreview } from "./types"; + +export type ApplicationFormValue = { + applicationId?: string; + modelType: string; + name: string; + parameters: Record; + selector: SelectorDescriptor; + timestep: { mode: "default" } | { mode: "clock"; dt: string; phase: string }; +}; + +export function ApplicationForm({ + mode, + models, + objects, + application, + initialModelType, + suggestedSelector, + nameReadOnly=false, + preview, + onPreview, + onSubmit, + onClose, +}: { + mode: "add" | "update"; + models: ModelDescriptor[]; + objects: ObjectGraphNode[]; + application?: ApplicationGraphNode; + initialModelType?: string; + suggestedSelector?: SelectorDescriptor; + nameReadOnly?: boolean; + preview: TargetPreview | null; + onPreview: (selector: SelectorDescriptor) => void; + onSubmit: (value: ApplicationFormValue) => void; + onClose: () => void; +}) { + const initialType = application?.modelType || initialModelType || models[0]?.type || ""; + const [modelType, setModelType] = useState(initialType); + const model = models.find((item) => item.type === modelType) ?? null; + const [name, setName] = useState(application?.name || application?.applicationId || defaultApplicationName(model)); + const [parameters, setParameters] = useState>(() => parameterDefaults(model, application)); + const initialSelector = application?.selector || suggestedSelector || defaultSelector(objects); + const [multiplicity, setMultiplicity] = useState(initialSelector.multiplicity); + const [scale, setScale] = useState(stringCriterion(initialSelector, "scale")); + const [kind, setKind] = useState(stringCriterion(initialSelector, "kind")); + const [species, setSpecies] = useState(stringCriterion(initialSelector, "species")); + const [objectName, setObjectName] = useState(stringCriterion(initialSelector, "name")); + const initialWithin = structuredCriterion(initialSelector, "within"); + const [scope, setScope] = useState(initialWithin?.type === "Scope" ? "named_scope" : "scene"); + const [scopeName, setScopeName] = useState(initialWithin?.type === "Scope" ? String(initialWithin.name || "") : ""); + const [timestepMode, setTimestepMode] = useState<"default" | "clock">(application?.timestep ? "clock" : "default"); + const [dt, setDt] = useState("1.0"); + const [phase, setPhase] = useState("0.0"); + + useEffect(() => { + const selected = models.find((item) => item.type === modelType) ?? null; + setParameters(parameterDefaults(selected, mode === "update" ? application : undefined)); + if (mode === "add") setName(defaultApplicationName(selected)); + }, [application, mode, modelType, models]); + + const options = useMemo(() => ({ + scales: unique(objects.map((object) => object.scale)), + kinds: unique(objects.map((object) => object.kind)), + species: unique(objects.map((object) => object.species)), + names: unique(objects.map((object) => object.name)), + }), [objects]); + + const targetSummary = useMemo(() => { + const clauses = [scale && `scale ${scale}`, kind && `kind ${kind}`, species && `species ${species}`, objectName && `name ${objectName}`].filter(Boolean); + return clauses.length ? clauses.join(", ") : "all scene objects"; + }, [kind, objectName, scale, species]); + + const selector = (): SelectorDescriptor => { + const criteria: Record = { selectors: [] }; + criteria.within = scope === "named_scope" && scopeName ? { type: "Scope", name: scopeName } : { type: "SceneScope" }; + if (scale) criteria.scale = scale; + if (kind) criteria.kind = kind; + if (species) criteria.species = species; + if (objectName) criteria.name = objectName; + return { type: selectorType(multiplicity), multiplicity, criteria, julia: "" }; + }; + + const submit = () => { + onSubmit({ + applicationId: application?.applicationId, + modelType, + name: name.trim(), + parameters, + selector: selector(), + timestep: timestepMode === "clock" ? { mode: "clock", dt, phase } : { mode: "default" }, + }); + }; + + return ( +
+
event.stopPropagation()} data-testid="application-form"> +
{mode === "add" ? "Add application" : `Update ${application?.applicationId}`}A configured use of a model on selected scene objects
+
+ + + + {model && model.constructor.fields.length > 0 &&
Model parameters
} + +
Target selector +
+ + + {scope === "named_scope" && } + + + + +
+

Julia will resolve {multiplicity.replace("_", " ")} target from {targetSummary}.

+ + {preview &&
{preview.count} target object{preview.count === 1 ? "" : "s"}{preview.objectIds.map(String).join(", ") || "No targets"}
} +
+ +
Timestep
{timestepMode === "clock" && <>}
+
+
+
+
+ ); +} + +export function ParameterFields({ fields, values, onChange }: { fields: ModelConstructorField[]; values: Record; onChange: (value: Record) => void }) { + const firstByGroup = new Map(); + for (const field of fields) if (field.typeParameter && !firstByGroup.has(field.typeParameter)) firstByGroup.set(field.typeParameter, field.name); + const updateType = (field: ModelConstructorField, type: string) => { + const names = field.typeParameter ? fields.filter((item) => item.typeParameter === field.typeParameter).map((item) => item.name) : [field.name]; + onChange(Object.fromEntries(Object.entries(values).map(([name, value]) => [name, names.includes(name) ? { ...value, type } : value]))); + }; + return
{fields.map((field) => { const value = values[field.name] || { type: field.inferredChoice, value: "" }; const showType = !field.typeParameter || firstByGroup.get(field.typeParameter) === field.name; return
{showType && }
; })}
; +} + +function SelectCriterion({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) { + return ; +} + +export function parameterDefaults(model: ModelDescriptor | null, application?: ApplicationGraphNode) { + if (!model) return {}; + return Object.fromEntries(model.constructor.fields.map((field) => { + const current = application?.modelParameters[field.name]; + const type = current?.type || field.inferredChoice; + const value = current ? current.julia : field.hasDefault ? type === "julia" ? field.defaultJulia || "" : displayDefault(field.default, type) : ""; + return [field.name, { type, value }]; + })); +} + +function displayDefault(value: unknown, type: string) { + const text = value === null || value === undefined ? "" : String(value); + return type === "symbol" ? text.replace(/^:/, "") : text; +} + +function defaultApplicationName(model: ModelDescriptor | null) { return model?.process || model?.name || "application"; } +function defaultSelector(objects: ObjectGraphNode[]): SelectorDescriptor { const scale = unique(objects.map((object) => object.scale))[0]; return { type: "Many", multiplicity: "many", criteria: scale ? { selectors: [], scale } : { selectors: [] }, julia: "" }; } +function stringCriterion(selector: SelectorDescriptor, key: string) { const value = selector.criteria[key]; return typeof value === "string" ? value : ""; } +function structuredCriterion(selector: SelectorDescriptor, key: string) { const value = selector.criteria[key]; return value && typeof value === "object" ? value as Record : null; } +function selectorType(value: SelectorDescriptor["multiplicity"]) { return value === "one" ? "One" : value === "optional_one" ? "OptionalOne" : "Many"; } +function unique(values: Array) { return [...new Set(values.filter((value): value is string => Boolean(value)))].sort(); } diff --git a/frontend/src/BindingForm.tsx b/frontend/src/BindingForm.tsx new file mode 100644 index 000000000..951a9faa6 --- /dev/null +++ b/frontend/src/BindingForm.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import { Eye, Link2, X } from "lucide-react"; +import type { ApplicationGraphNode, GraphPort, ObjectGraphNode, SelectorDescriptor, SelectorPreview } from "./types"; + +export type BindingFormValue = { + applicationId: string; + input: string; + selector: SelectorDescriptor; +}; + +export type BindingEndpoints = { + sourceApplication: ApplicationGraphNode; + sourcePort: GraphPort; + targetApplication: ApplicationGraphNode; + targetPort: GraphPort; +}; + +type BindingFormProps = { + endpoints: BindingEndpoints; + objects: ObjectGraphNode[]; + preview: SelectorPreview | null; + onPreview: (value: BindingFormValue) => void; + onSubmit: (value: BindingFormValue) => void; + onClose: () => void; +}; + +export function BindingForm({ endpoints, objects, preview, onPreview, onSubmit, onClose }: BindingFormProps) { + const sameTargets = sameValues(endpoints.sourceApplication.targetIds, endpoints.targetApplication.targetIds); + const [multiplicity, setMultiplicity] = useState(endpoints.sourceApplication.targetCount > 1 && endpoints.targetApplication.targetCount === 1 ? "many" : "one"); + const [relation, setRelation] = useState(sameTargets ? "self" : ""); + const [scope, setScope] = useState("scene"); + const [scopeName, setScopeName] = useState(""); + const [ancestorScale, setAncestorScale] = useState(""); + const [scale, setScale] = useState(onlyOrEmpty(endpoints.sourceApplication.targetScales)); + const [kind, setKind] = useState(onlyOrEmpty(endpoints.sourceApplication.targetKinds)); + const [species, setSpecies] = useState(onlyOrEmpty(endpoints.sourceApplication.targetSpecies)); + const [sourceName, setSourceName] = useState(""); + const [sourceFilter, setSourceFilter] = useState<"application" | "process">("application"); + const [policy, setPolicy] = useState("automatic"); + const [window, setWindow] = useState(""); + const scales = unique(objects.map((object) => object.scale)); + const kinds = unique(objects.map((object) => object.kind)); + const speciesOptions = unique(objects.map((object) => object.species)); + const names = unique(objects.map((object) => object.name)); + + const value = (): BindingFormValue => { + const criteria: Record = { + selectors: [], + var: endpoints.sourcePort.name, + }; + criteria[sourceFilter] = sourceFilter === "application" + ? endpoints.sourceApplication.applicationId + : endpoints.sourceApplication.process; + const within = scopeDescriptor(scope, scopeName, ancestorScale); + if (within) criteria.within = within; + if (relation) criteria.relation = relation; + if (scale) criteria.scale = scale; + if (kind) criteria.kind = kind; + if (species) criteria.species = species; + if (sourceName) criteria.name = sourceName; + if (policy !== "automatic") criteria.policy = { type: policyType(policy) }; + if (window.trim()) { + const parsed = Number(window); + if (Number.isFinite(parsed)) criteria.window = parsed; + } + return { + applicationId: endpoints.targetApplication.applicationId, + input: endpoints.targetPort.name, + selector: { type: selectorType(multiplicity), multiplicity, criteria, julia: "" }, + }; + }; + + return
+
event.stopPropagation()} data-testid="binding-form"> +
Connect applicationsJulia resolves this declaration into concrete object bindings
+
+
Producer{endpoints.sourceApplication.applicationId}{endpoints.sourcePort.name}
Consumer{endpoints.targetApplication.applicationId}{endpoints.targetPort.name}
+
Source object selector
+ + + {scope === "ancestor" && } + {scope === "named_scope" && } + + + + + + + + +
+ {preview &&
+ {preview.bindingCount} resolved binding{preview.bindingCount === 1 ? "" : "s"} + {preview.consumerObjectIds.length} consumer object{preview.consumerObjectIds.length === 1 ? "" : "s"} from {preview.sourceObjectIds.length} source object{preview.sourceObjectIds.length === 1 ? "" : "s"} + {preview.sourceApplicationIds.length > 0 && {preview.sourceApplicationIds.join(", ")}} + {preview.diagnostics.map((diagnostic) =>

{diagnostic}

)} +
} +
+
+
+
; +} + +function Criterion({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) { + return ; +} + +function sameValues(left: unknown[], right: unknown[]) { return left.length === right.length && left.every((value) => right.some((other) => String(other) === String(value))); } +function onlyOrEmpty(values: string[]) { return values.length === 1 ? values[0] : ""; } +function selectorType(value: SelectorDescriptor["multiplicity"]) { return value === "one" ? "One" : value === "optional_one" ? "OptionalOne" : "Many"; } +function unique(values: Array) { return [...new Set(values.filter((value): value is string => Boolean(value)))].sort(); } +function policyType(value: string) { return value === "hold_last" ? "HoldLast" : value === "interpolate" ? "Interpolate" : value === "integrate" ? "Integrate" : "Aggregate"; } +function scopeDescriptor(scope: string, name: string, scale: string) { + if (scope === "scene") return { type: "SceneScope" }; + if (scope === "self") return { type: "Self" }; + if (scope === "subtree") return { type: "Subtree" }; + if (scope === "self_plant") return { type: "SelfPlant" }; + if (scope === "ancestor") return { type: "Ancestor", scale: scale || null }; + if (scope === "named_scope" && name) return { type: "Scope", name }; + return null; +} diff --git a/frontend/src/DependencyEdge.tsx b/frontend/src/DependencyEdge.tsx new file mode 100644 index 000000000..368a1b66c --- /dev/null +++ b/frontend/src/DependencyEdge.tsx @@ -0,0 +1,54 @@ +import { BaseEdge, EdgeLabelRenderer, Position, getSmoothStepPath, type Edge, type EdgeProps } from "@xyflow/react"; +import type { ModelGraphEdge } from "./types"; + +type SceneFlowEdge = Edge; + +export function DependencyEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition = Position.Right, + targetPosition = Position.Left, + markerEnd, + style, + data, +}: EdgeProps) { + const [path, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: 14, + offset: 24, + }); + const label = edgeLabel(data); + return ( + <> + + {label && ( + +
+ {label} +
+
+ )} + + ); +} + +function edgeLabel(data?: ModelGraphEdge) { + if (!data) return ""; + if (data.kind === "manual_call") return data.call || "call"; + if (data.kind === "object_topology" || data.kind === "application_target") return ""; + if (data.sourceVariable && data.targetVariable) { + return data.sourceVariable === data.targetVariable ? data.sourceVariable : `${data.sourceVariable} → ${data.targetVariable}`; + } + return data.kind.replaceAll("_", " "); +} diff --git a/frontend/src/ErrorBoundary.test.ts b/frontend/src/ErrorBoundary.test.ts new file mode 100644 index 000000000..d774feef6 --- /dev/null +++ b/frontend/src/ErrorBoundary.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { ErrorBoundary } from "./ErrorBoundary"; + +describe("ErrorBoundary", () => { + it("captures a rendering error as recoverable state", () => { + const error = new Error("panel failed"); + expect(ErrorBoundary.getDerivedStateFromError(error)).toEqual({ error }); + }); +}); diff --git a/frontend/src/ErrorBoundary.tsx b/frontend/src/ErrorBoundary.tsx new file mode 100644 index 000000000..1b86a8b7d --- /dev/null +++ b/frontend/src/ErrorBoundary.tsx @@ -0,0 +1,24 @@ +import { AlertTriangle, RotateCcw } from "lucide-react"; +import { Component, type ErrorInfo, type ReactNode } from "react"; + +export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("PlantSimEngine model graph frontend failed", error, info); + } + + render() { + if (!this.state.error) return this.props.children; + return
+ +

The graph view could not be rendered

+

{this.state.error.message}

+ +
; + } +} diff --git a/frontend/src/ModelNode.tsx b/frontend/src/ModelNode.tsx new file mode 100644 index 000000000..654461149 --- /dev/null +++ b/frontend/src/ModelNode.tsx @@ -0,0 +1,238 @@ +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; +import { Box, Clock3, Layers3, Plus, Scissors } from "lucide-react"; +import type { GraphPort, RuntimeApplicationNode, RuntimeEntityNode } from "./types"; +import { nodeWidth } from "./nodeSizing"; + +type ApplicationFlowNode = Node; +type EntityFlowNode = Node; + +export function ApplicationNode({ data, selected }: NodeProps) { + const overview = data.detailMode === "overview"; + const required = new Set(data.requiredInputPortIds); + const candidates = new Set(data.candidatePortIds); + const previous = new Set(data.previousTimeStepPortIds); + const cycleBreaks = new Set(data.cycleBreakInputPortIds); + const scope = scopeLabel(data); + + return ( +
+ {overview && } +
+
+
{data.name || data.applicationId}
+
{data.modelName}
+
+ +
+ {overview ? ( +
+ {data.targetCount} targets + {data.inputs.length} in + {data.outputs.length} out +
+ ) : ( + <> +
+ + {scope} + + + {rateLabel(data)} + +
+
+ {data.targetCount} concrete target{data.targetCount === 1 ? "" : "s"} +
+
+ + +
+ {(data.environmentInputs.length > 0 || data.environmentOutputs.length > 0) &&
+ + +
} + + )} +
+ ); +} + +export function EntityNode({ data, selected }: NodeProps) { + return ( +
+ {(data.inputPortIds?.length ? data.inputPortIds : [undefined]).map((id, index) => ( + + ))} +
+ {data.title} + {data.subtitle} +
+
+ {data.badges.map((badge) => {badge})} +
+ {(data.outputPortIds?.length ? data.outputPortIds : [undefined]).map((id, index) => ( + + ))} +
+ ); +} + +function PortColumn({ + title, + side, + ports, + required, + candidates, + previous, + cycleBreaks, + cycleBreakMode, + application, + onCandidateClick, + onPortClick, + onCycleBreak, +}: { + title: string; + side: "input" | "output"; + ports: GraphPort[]; + required: Set; + candidates: Set; + previous: Set; + cycleBreaks: Set; + cycleBreakMode: boolean; + application: RuntimeApplicationNode; + onCandidateClick?: RuntimeApplicationNode["onCandidateClick"]; + onPortClick?: RuntimeApplicationNode["onPortClick"]; + onCycleBreak?: RuntimeApplicationNode["onCycleBreak"]; +}) { + return ( +
+
{title}
+ {ports.map((port) => ( +
{ + event.stopPropagation(); + onPortClick?.(port); + }} + > + {side === "input" && } + {port.name} + {candidates.has(port.id) && ( + + )} + {side === "input" && cycleBreakMode && cycleBreaks.has(port.id) && ( + + )} + {previous.has(port.id) && t-1} + {side === "output" && } +
+ ))} +
+ ); +} + +function OverviewHandles({ inputs, outputs }: { inputs: GraphPort[]; outputs: GraphPort[] }) { + return ( + <> + {inputs.map((port, index) => ( + + ))} + {outputs.map((port, index) => ( + + ))} + + ); +} + +function handlePosition(index: number, total: number) { + return total <= 1 ? 52 : 28 + (index / (total - 1)) * 48; +} + +function scopeLabel(data: RuntimeApplicationNode) { + const labels = [...data.targetInstances, ...data.targetScales, ...data.targetKinds]; + return labels.length > 0 ? labels.slice(0, 2).join(" / ") : data.selector.type; +} + +function rateLabel(data: RuntimeApplicationNode) { + if (data.timestep === null || data.timestep === undefined) return "default rate"; + return String(data.timestep); +} diff --git a/frontend/src/ObjectForm.tsx b/frontend/src/ObjectForm.tsx new file mode 100644 index 000000000..ec6693f46 --- /dev/null +++ b/frontend/src/ObjectForm.tsx @@ -0,0 +1,73 @@ +import { Check, X } from "lucide-react"; +import { useMemo, useState } from "react"; +import type { ObjectGraphNode } from "./types"; + +export type ObjectFormValue = { + objectId: string; + configuration: { + parent: string | null; + scale: string | null; + kind: string | null; + species: string | null; + name: string | null; + }; +}; + +export function ObjectForm({ + mode, + objects, + object, + onSubmit, + onClose, +}: { + mode: "add" | "update"; + objects: ObjectGraphNode[]; + object?: ObjectGraphNode; + onSubmit: (value: ObjectFormValue) => void; + onClose: () => void; +}) { + const [objectId, setObjectId] = useState(String(object?.objectId ?? "")); + const [parent, setParent] = useState(parentObjectId(object?.parent)); + const [scale, setScale] = useState(object?.scale || ""); + const [kind, setKind] = useState(object?.kind || ""); + const [species, setSpecies] = useState(object?.species || ""); + const [name, setName] = useState(object?.name || ""); + const parentOptions = useMemo( + () => objects.filter((item) => String(item.objectId) !== objectId), + [objectId, objects], + ); + + const submit = () => onSubmit({ + objectId: objectId.trim(), + configuration: { + parent: parent || null, + scale: scale.trim() || null, + kind: kind.trim() || null, + species: species.trim() || null, + name: name.trim() || null, + }, + }); + + return
+
event.stopPropagation()} data-testid="object-form"> +
{mode === "add" ? "Add scene object" : `Update object ${String(object?.objectId)}`}Objects define the concrete entities and topology targeted by applications
+
+ + +
+ + + + +
+
+
+
+
; +} + +function parentObjectId(parent: unknown | null | undefined) { + if (parent === null || parent === undefined || parent === "") return ""; + const value = String(parent); + return value.startsWith("object:") ? value.slice("object:".length) : value; +} diff --git a/frontend/src/OverrideForm.tsx b/frontend/src/OverrideForm.tsx new file mode 100644 index 000000000..bce13b057 --- /dev/null +++ b/frontend/src/OverrideForm.tsx @@ -0,0 +1,86 @@ +import { Check, Trash2, X } from "lucide-react"; +import { useMemo, useState } from "react"; +import { ParameterFields, parameterDefaults } from "./ApplicationForm"; +import type { ApplicationGraphNode, InstanceDescriptor, ModelDescriptor } from "./types"; + +export type OverrideFormValue = { + scope: "instance" | "object"; + instance: string; + objectId?: unknown; + applicationId: string; + modelType: string; + parameters: Record; +}; + +export function OverrideForm({ + application, + models, + instances, + onSubmit, + onRemove, + onClose, +}: { + application: ApplicationGraphNode; + models: ModelDescriptor[]; + instances: InstanceDescriptor[]; + onSubmit: (value: OverrideFormValue) => void; + onRemove: (value: OverrideFormValue) => void; + onClose: () => void; +}) { + const matchingModels = useMemo( + () => models.filter((model) => model.process === application.process), + [application.process, models], + ); + const [scope, setScope] = useState<"instance" | "object">("instance"); + const [instanceName, setInstanceName] = useState(application.targetInstances[0] || instances[0]?.name || ""); + const instance = instances.find((item) => item.name === instanceName); + const objectIds = (instance?.objectIds || []).filter((id) => application.targetIds.some((target) => String(target) === String(id))); + const [objectId, setObjectId] = useState(objectIds[0] ?? ""); + const [modelType, setModelType] = useState(application.modelType); + const model = matchingModels.find((item) => item.type === modelType) || matchingModels[0] || null; + const [parameters, setParameters] = useState(() => parameterDefaults(model, application)); + const baseApplicationId = mountedApplicationName(application.applicationId, instanceName); + const hasInstanceOverride = Boolean(instance?.instanceOverrides.includes(baseApplicationId)); + const hasObjectOverride = Boolean(instance?.objectOverrides.some((entry) => { + const record = entry as Record; + return String(record.object ?? record.objectId ?? "") === String(objectId) && + String(record.application ?? record.applicationId ?? "") === baseApplicationId; + })); + const canRemove = scope === "instance" ? hasInstanceOverride : hasObjectOverride; + + const selectModel = (value: string) => { + setModelType(value); + setParameters(parameterDefaults(matchingModels.find((item) => item.type === value) || null)); + }; + + return
+
event.stopPropagation()} data-testid="override-form"> +
Create a model overrideThe shared template remains unchanged outside the selected scope
+
+
+ + +
+
+ + {scope === "object" && } + +
+ {model && model.constructor.fields.length > 0 &&
Model parameters
} +
{scope === "instance" ? `Override ${instanceName}` : `Override object ${String(objectId)}`}Julia validates that the replacement keeps the same process and declared variable contract.
+
+
{canRemove && }
+
+
; +} + +function mountedApplicationName(applicationId: string, instance: string) { + const prefix = `${instance}__`; + return applicationId.startsWith(prefix) ? applicationId.slice(prefix.length) : applicationId; +} diff --git a/frontend/src/elkjs.d.ts b/frontend/src/elkjs.d.ts new file mode 100644 index 000000000..519073945 --- /dev/null +++ b/frontend/src/elkjs.d.ts @@ -0,0 +1,3 @@ +declare module "elkjs/lib/elk.bundled.js" { + export { default } from "elkjs"; +} diff --git a/frontend/src/layout.ts b/frontend/src/layout.ts new file mode 100644 index 000000000..a2341386d --- /dev/null +++ b/frontend/src/layout.ts @@ -0,0 +1,70 @@ +import ELK from "elkjs/lib/elk.bundled.js"; +import type { Edge, Node } from "@xyflow/react"; +import type { RuntimeApplicationNode, RuntimeEntityNode, ModelGraphEdge } from "./types"; +import { nodeWidth } from "./nodeSizing"; + +const elk = new ELK(); +export type LayoutMode = "data_flow" | "compact" | "overview" | "topology"; +type RuntimeNode = RuntimeApplicationNode | RuntimeEntityNode; + +export async function layoutGraph(nodes: Node[], edges: Edge[], mode: LayoutMode) { + const graph = { + id: "root", + layoutOptions: options(mode), + children: nodes.map((node) => ({ + id: node.id, + width: width(node.data), + height: height(node.data), + ports: node.data.nodeKind === "application" ? [ + ...node.data.inputs.map((port, index) => portDescriptor(port.id, "WEST", index)), + ...node.data.outputs.map((port, index) => portDescriptor(port.id, "EAST", index)), + ] : [ + ...(node.data.inputPortIds ?? []).map((id, index) => portDescriptor(id, "WEST", index)), + ...(node.data.outputPortIds ?? []).map((id, index) => portDescriptor(id, "EAST", index)), + ], + layoutOptions: { "org.eclipse.elk.portConstraints": "FIXED_ORDER" }, + })), + edges: edges.map((edge) => ({ + id: edge.id, + sources: [edge.sourceHandle ?? edge.source], + targets: [edge.targetHandle ?? edge.target], + })), + }; + const result = await elk.layout(graph); + const positions = new Map((result.children ?? []).map((child) => [child.id, { x: child.x ?? 0, y: child.y ?? 0 }])); + return nodes.map((node) => ({ ...node, position: positions.get(node.id) ?? node.position })); +} + +function options(mode: LayoutMode): Record { + return { + "elk.algorithm": mode === "topology" ? "mrtree" : "layered", + "elk.direction": mode === "topology" ? "DOWN" : "RIGHT", + "elk.spacing.nodeNode": mode === "overview" ? "24" : mode === "compact" ? "32" : "56", + "elk.layered.spacing.nodeNodeBetweenLayers": mode === "overview" ? "48" : mode === "compact" ? "60" : "110", + "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", + "elk.layered.crossingMinimization.semiInteractive": "true", + "elk.edgeRouting": "ORTHOGONAL", + }; +} + +function portDescriptor(id: string, side: "WEST" | "EAST", index: number) { + return { + id, + width: 9, + height: 9, + layoutOptions: { + "org.eclipse.elk.port.side": side, + "org.eclipse.elk.port.index": String(index), + }, + }; +} + +function width(data: RuntimeNode) { + return data.nodeKind === "application" ? nodeWidth(data) : 240; +} + +function height(data: RuntimeNode) { + if (data.nodeKind !== "application") return 112; + if (data.detailMode === "overview") return 108; + return Math.max(178, 142 + Math.max(data.inputs.length, data.outputs.length) * 27); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 000000000..1a7dc9525 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import { ErrorBoundary } from "./ErrorBoundary"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/frontend/src/nodeSizing.ts b/frontend/src/nodeSizing.ts new file mode 100644 index 000000000..3db28fc78 --- /dev/null +++ b/frontend/src/nodeSizing.ts @@ -0,0 +1,12 @@ +import type { RuntimeApplicationNode } from "./types"; + +export function nodeWidth(node: RuntimeApplicationNode) { + if (node.detailMode === "overview") return 190; + const longest = Math.max( + node.applicationId.length, + node.modelName.length, + ...node.inputs.map((port) => port.name.length), + ...node.outputs.map((port) => port.name.length), + ); + return Math.max(310, Math.min(540, 235 + longest * 7)); +} diff --git a/frontend/src/sampleModelGraph.ts b/frontend/src/sampleModelGraph.ts new file mode 100644 index 000000000..095bc6a81 --- /dev/null +++ b/frontend/src/sampleModelGraph.ts @@ -0,0 +1,90 @@ +import type { ApplicationGraphNode, GraphPort, ModelGraphEdge, ModelGraphView } from "./types"; + +const source = application("source", "degree_days", "ToyDegreeDaysCumulModel", [], ["TT_cu"]); +const lai = application("lai", "lai_dynamic", "ToyLAIModel", ["TT_cu"], ["LAI"]); +const light = application("light", "light_interception", "Beer", ["LAI"], ["aPPFD"]); + +export const sampleModelGraph: ModelGraphView = { + schemaVersion: 1, + level: "applications", + metadata: { + title: "PlantSimEngine Model Graph", + modelRevision: 0, + objectCount: 1, + instanceCount: 0, + applicationCount: 3, + executionCount: 3, + bindingCount: 2, + callCount: 0, + unresolvedInitializationCount: 1, + cyclic: false, + strictlyCompiled: true, + }, + objects: [{ id: "object:plant", objectId: "plant", scale: "Plant", kind: "plant", species: null, name: "plant", instance: null, parent: null, children: [], hasGeometry: false, hasStatus: true }], + instances: [], + applications: [source, lai, light], + executions: [source, lai, light].map((item) => ({ id: `execution:${item.applicationId}:plant`, applicationId: item.applicationId, applicationNodeId: item.id, objectId: "plant", objectNodeId: "object:plant", modelType: item.modelType, modelParameters: {}, overridden: false })), + edges: [edge(source, "TT_cu", lai, "TT_cu"), edge(lai, "LAI", light, "LAI")], + modelLibrary: [], + initialization: [{ applicationId: "source", objectId: "plant", variable: "TT", role: "input", disposition: "unresolved", value: "-Inf", valueJulia: "-Inf", expectedType: "Float64", sourceApplicationIds: [], sourceObjectIds: [], sourceVariable: null, origin: "missing", previousTimeStep: false }], + diagnostics: [], + cycles: [], + availableActions: ["inspect"], +}; + +function application(applicationId: string, process: string, modelName: string, inputs: string[], outputs: string[]): ApplicationGraphNode { + return { + id: `application:${applicationId}`, + applicationId, + name: applicationId, + process, + modelType: modelName, + modelName, + module: "PlantSimEngine.Examples", + package: "PlantSimEngine", + modelParameters: {}, + selector: { type: "One", multiplicity: "one", criteria: { scale: "Plant" }, julia: "One(scale=:Plant)" }, + targetIds: ["plant"], + targetCount: 1, + targetScales: ["Plant"], + targetKinds: ["plant"], + targetSpecies: [], + targetInstances: [], + timestep: null, + clock: null, + inputs: inputs.map((name) => port(applicationId, "input", name)), + outputs: outputs.map((name) => port(applicationId, "output", name)), + environmentInputs: [], + environmentOutputs: [], + inputBindings: {}, + callBindings: {}, + environment: null, + meteoBindings: {}, + meteoWindow: null, + outputRouting: {}, + updates: [], + modelStorage: "shared_application", + objectOverrides: [], + }; +} + +function port(applicationId: string, role: "input" | "output", name: string): GraphPort { + return { id: `application:${applicationId}:${role}:${name}`, name, role, default: "-Inf", defaultJulia: "-Inf", expectedType: "Float64" }; +} + +function edge(sourceApplication: ApplicationGraphNode, sourceVariable: string, targetApplication: ApplicationGraphNode, targetVariable: string): ModelGraphEdge { + return { + id: `binding:${sourceApplication.applicationId}:${sourceVariable}:${targetApplication.applicationId}:${targetVariable}`, + source: sourceApplication.id, + target: targetApplication.id, + sourcePort: `application:${sourceApplication.applicationId}:output:${sourceVariable}`, + targetPort: `application:${targetApplication.applicationId}:input:${targetVariable}`, + sourceVariable, + targetVariable, + sourceApplicationId: sourceApplication.applicationId, + targetApplicationId: targetApplication.applicationId, + kind: "inferred_same_object", + cycle: false, + projection: "applications", + }; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 000000000..27df9c692 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,2697 @@ +:root { + --bg: #f3eee6; + --paper: #fffaf2; + --paper-strong: #fbf2e6; + --ink: #312721; + --muted: #80756c; + --line: #ded2c3; + --line-strong: #b7a696; + --accent: #1f7a53; + --accent-soft: rgba(31, 122, 83, 0.12); + --sage: #7f8f73; + --sage-dark: #596851; + --ochre: #c99035; + --clay: #bf6a54; + --shadow: rgba(56, 43, 35, 0.12); +} + +/* Composite model/object graph viewer */ +.model-editor-shell { + height: 100vh; + min-height: 560px; + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr); + color: #302923; + background: #f3eee5; +} +.frontend-error { min-height: 100vh; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 24px; color: #743128; background: #fff5ef; text-align: center; } +.frontend-error h1,.frontend-error p { margin: 0; } +.frontend-error p { max-width: 620px; color: #655850; } +.frontend-error button { display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; padding: 8px 11px; border: 1px solid #b95040; border-radius: 5px; color: #fff; background: #b94435; } + +.model-toolbar { + z-index: 20; + display: grid; + grid-template-columns: auto minmax(260px, 1fr) auto; + gap: 12px 18px; + align-items: center; + padding: 14px 18px; + background: #fffaf2; + border-bottom: 1px solid #d9cdbd; + box-shadow: 0 8px 24px rgba(60, 48, 37, 0.08); +} + +.model-brand { display: flex; align-items: center; gap: 11px; } +.model-brand .brand-mark { width: 5px; height: 42px; border-radius: 3px; background: #1f7a58; } +.model-brand div { display: grid; } +.model-brand small { color: #81766c; font: 10px/1.2 ui-monospace, monospace; letter-spacing: 0; } +.model-brand strong { font-size: 20px; letter-spacing: 0; } + +.model-search { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 11px; + border: 1px solid #d9cdbd; + border-radius: 6px; + background: #fffdf8; +} +.model-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; font: inherit; } +.model-search button, +.model-toolbar button, +.overlay-panel button, +.candidate-popover button, +.editor-feedback button { border: 0; background: transparent; color: inherit; cursor: pointer; } + +.model-counts { display: flex; align-items: center; justify-content: flex-end; gap: 7px; flex-wrap: wrap; } +.model-counts > span, +.model-counts > button { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 8px; + border: 1px solid #d9cdbd; + border-radius: 5px; + background: #fffaf2; + font: 12px ui-monospace, monospace; +} +.model-counts .count-warning { color: #a96a13; border-color: #dfbd83; } +.model-counts .count-error { color: #b44e3c; border-color: #dfa496; } + +.view-tabs, +.model-actions { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.view-tabs { grid-column: 1 / 3; } +.model-actions { justify-content: flex-end; } +.view-tabs button, +.model-actions button, +.cycle-callout button { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 6px 10px; + border: 1px solid #d4c7b6; + border-radius: 5px; + background: #fffaf2; + color: #4c433b; +} +.view-tabs button.active { color: #176047; border-color: #83b59e; background: #e9f4ed; } +.model-actions button:disabled { opacity: 0.4; cursor: default; } +.model-actions .overview-cta { color: #176047; border-color: #86bba4; background: #e9f4ed; font-weight: 700; } + +.cycle-callout { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 18px; + color: #8f2f24; + background: #fff0eb; + border-bottom: 1px solid #df9a8e; +} +.cycle-callout div { display: grid; margin-right: auto; } +.cycle-callout span { font-size: 12px; } +.cycle-callout button { border-color: #cf7668; color: #8f2f24; } +.cycle-callout button.active { color: #fff; border-color: #9d3428; background: #b94435; } + +.editor-feedback { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 7px 16px; + background: #fff5d9; + border-bottom: 1px solid #dfc278; + color: #6e5315; + font-size: 12px; +} + +.graph-scope-filter { + align-items: center; + background: #edf5ef; + border-bottom: 1px solid #b9d4c0; + color: #365849; + display: flex; + font-size: 12px; + gap: 10px; + justify-content: center; + min-height: 38px; + padding: 6px 14px; +} + +.graph-scope-filter button { + align-items: center; + background: #fff; + border: 1px solid #b9d4c0; + border-radius: 5px; + color: #365849; + cursor: pointer; + display: inline-flex; + gap: 5px; + padding: 5px 8px; +} + +.model-workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 310px; } +.flow-wrap { min-width: 0; min-height: 0; position: relative; } +.model-inspector { overflow: auto; padding: 16px; background: #fffaf2; border-left: 1px solid #d9cdbd; } +.model-inspector > header { display: grid; gap: 2px; margin-bottom: 14px; } +.model-inspector > header span { color: #776c63; font: 11px ui-monospace, monospace; } +.model-inspector pre { overflow-wrap: anywhere; white-space: pre-wrap; font-size: 10px; line-height: 1.45; } +.empty-inspector { display: grid; place-items: center; padding: 48px 20px; color: #8a7e74; text-align: center; } +.inspector-initialization > div { display: flex; justify-content: space-between; gap: 8px; padding: 6px 0; border-bottom: 1px solid #eee4d6; font-size: 11px; } +.inspector-initialization .unresolved { color: #b74635; font-weight: 700; } + +.application-node .target-summary { margin: -2px 0 10px; color: #766c63; font-size: 11px; } +.application-node .target-summary strong { color: #2d6652; } +.application-node .previous-label { margin-left: auto; color: #1f7a58; font: 10px ui-monospace, monospace; } +.cycle-port-break { + display: inline-grid; + place-items: center; + width: 24px; + height: 24px; + margin-left: auto; + border: 1px solid #c94e3e !important; + border-radius: 50%; + color: #a63428 !important; + background: #fff0eb !important; + box-shadow: 0 3px 9px rgba(140, 45, 35, 0.18); +} +.cycle-port-break:hover { color: #fff !important; background: #bd4435 !important; } + +.entity-node { + position: relative; + width: 240px; + min-height: 105px; + padding: 13px; + border: 1px solid #d8cbbb; + border-radius: 6px; + background: #fffaf2; + box-shadow: 0 7px 18px rgba(57, 46, 36, 0.12); +} +.entity-node.selected { border-color: #1f7a58; box-shadow: 0 0 0 2px rgba(31, 122, 88, 0.16); } +.entity-node header { display: grid; gap: 3px; } +.entity-node header span { color: #766c63; font-size: 11px; } +.entity-node .badges { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 12px; } + +.candidate-popover { + position: fixed; + z-index: 80; + width: 370px; + max-height: 460px; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + border: 1px solid #cfbfaa; + border-radius: 7px; + background: #fffaf2; + box-shadow: 0 20px 54px rgba(52, 42, 32, 0.24); +} +.candidate-popover > header { display: flex; gap: 10px; padding: 12px; border-bottom: 1px solid #e1d5c5; } +.candidate-popover > header div { display: grid; margin-right: auto; } +.candidate-popover > header span { color: #7a7067; font-size: 10px; } +.candidate-list { overflow: auto; display: grid; gap: 7px; padding: 9px; } +.candidate-card { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; padding: 10px; border: 1px solid #ded2c2 !important; border-radius: 5px; background: #fffdf8 !important; text-align: left; } +.candidate-card:hover { border-color: #76a990 !important; background: #edf6f0 !important; } +.candidate-card span { color: #1f7053; font: 11px ui-monospace, monospace; } +.candidate-card small { color: #7a7067; } +.candidate-card div { grid-column: 1 / -1; color: #7a7067; font-size: 10px; } +.candidate-card.existing { border-left: 3px solid #1f7a58 !important; } +.candidate-section-label { + padding: 5px 3px 2px; + color: #71675e; + font: 700 10px ui-monospace, monospace; + text-transform: uppercase; +} + +.overlay-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 24px; background: rgba(45, 37, 30, 0.38); } +.overlay-panel { width: min(720px, 100%); max-height: min(760px, 92vh); display: grid; grid-template-rows: auto minmax(0, 1fr); border: 1px solid #cdbda8; border-radius: 7px; background: #fffaf2; box-shadow: 0 24px 70px rgba(39, 31, 25, 0.3); } +.overlay-panel > header { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; border-bottom: 1px solid #ded2c2; } +.overlay-content { overflow: auto; padding: 15px; } +.diagnostic-card,.cycle-card,.initialization-card { display: grid; gap: 5px; margin-bottom: 10px; padding: 11px; border: 1px solid #ded2c2; border-radius: 5px; } +.diagnostic-card { border-left: 3px solid #c85240; } +.diagnostic-card p,.cycle-card p { margin: 0; } +.diagnostic-card small { color: #766c63; } +.cycle-card button { width: fit-content; padding: 6px 8px; border: 1px solid #ce7768; border-radius: 4px; color: #963529; } +.model-code { min-height: 320px; margin: 0; padding: 14px; overflow: auto; border-radius: 5px; background: #292622; color: #f5eee5; } +.model-file-dialog { display: grid; gap: 14px; } +.model-file-dialog > p { margin: 0; line-height: 1.5; } +.model-file-dialog > label { display: grid; gap: 5px; color: #5f554d; font-size: 11px; font-weight: 700; } +.model-path-input { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; } +.model-path-input input { width: 100%; min-width: 0; padding: 8px 9px; border: 1px solid #d3c5b4; border-radius: 4px; background: #fffdf8; font: 12px ui-monospace, monospace; } +.model-path-input button { padding: 8px 13px; border: 1px solid #1d7052 !important; border-radius: 4px; color: #fff !important; background: #1f7a58 !important; } +.model-path-input button:disabled { opacity: .4; cursor: default; } +.model-file-dialog section { display: grid; gap: 7px; } +.recent-model-list { display: grid; gap: 6px; } +.recent-model-list button { display: grid; gap: 2px; padding: 9px; border: 1px solid #d8cbbb !important; border-radius: 5px; background: #fffdf8 !important; text-align: left; } +.recent-model-list button:hover { border-color: #73a88e !important; background: #edf6f0 !important; } +.recent-model-list small,.model-file-dialog > small { color: #776d64; overflow-wrap: anywhere; } +.recovery-path { padding: 9px; border: 1px dashed #c99035 !important; border-radius: 5px; color: #6d511d !important; background: #fff6e6 !important; text-align: left; overflow-wrap: anywhere; } +.initialization-group { display: grid; gap: 10px; margin-bottom: 12px; padding: 12px; border: 1px solid #d8cbbb; border-radius: 6px; background: #fffdf8; } +.initialization-group > header { display: flex; align-items: end; justify-content: space-between; gap: 12px; } +.initialization-group > header div { display: grid; } +.initialization-group > header span,.initialization-group > header small { color: #786d64; font: 10px ui-monospace, monospace; } +.initialization-group > p { margin: 0; color: #6c625a; font-size: 11px; line-height: 1.45; } +.initialization-value { display: grid; grid-template-columns: 120px minmax(0, 1fr) auto; gap: 8px; align-items: end; } +.initialization-value label { display: grid; gap: 4px; color: #5f554d; font-size: 10px; font-weight: 700; } +.initialization-value input,.initialization-value select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid #d3c5b4; border-radius: 4px; background: #fff; } +.initialization-value button,.initialization-object-list button { padding: 7px 9px; border: 1px solid #85ad99 !important; border-radius: 4px; color: #176047 !important; background: #edf6f0 !important; } +.initialization-value button:disabled,.initialization-object-list button:disabled { opacity: .4; cursor: default; } +.initialization-object-list { display: grid; gap: 5px; } +.initialization-object-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; align-items: center; padding-top: 5px; border-top: 1px solid #ece1d2; font-size: 11px; } +.initialization-object-list code { color: #a33d31; } + +@media (max-width: 980px) { + .model-toolbar { grid-template-columns: 1fr; } + .view-tabs { grid-column: auto; } + .model-counts,.model-actions { justify-content: flex-start; } + .model-workspace { grid-template-columns: 1fr; } + .model-inspector { display: none; } +} + +.application-form { width: min(780px, 100%); } +.object-form { width: min(640px, 100%); } +.override-form { width: min(760px, 100%); } +.application-form > header div { display: grid; gap: 2px; } +.object-form > header div { display: grid; gap: 2px; } +.override-form > header div { display: grid; gap: 2px; } +.application-form > header span { color: #786d64; font-size: 11px; } +.object-form > header span { color: #786d64; font-size: 11px; } +.override-form > header span { color: #786d64; font-size: 11px; } +.application-form-content { display: grid; gap: 14px; } +.object-form-content { display: grid; gap: 14px; } +.override-form-content { display: grid; gap: 14px; } +.application-form-content > label, +.application-form fieldset label, +.object-form-content label { display: grid; gap: 5px; color: #5f554d; font-size: 11px; font-weight: 700; } +.override-form-content label { display: grid; gap: 5px; color: #5f554d; font-size: 11px; font-weight: 700; } +.application-form input, +.application-form select, +.object-form input, +.object-form select { + width: 100%; + min-width: 0; + padding: 8px 9px; + border: 1px solid #d3c5b4; + border-radius: 4px; + background: #fffdf8; + color: #332c26; + font: 12px ui-monospace, monospace; +} +.override-form input,.override-form select { width: 100%; min-width: 0; padding: 8px 9px; border: 1px solid #d3c5b4; border-radius: 4px; background: #fffdf8; color: #332c26; font: 12px ui-monospace, monospace; } +.override-form fieldset { margin: 0; padding: 12px; border: 1px solid #ded2c2; border-radius: 5px; } +.override-form legend { padding: 0 6px; color: #2d6652; font-weight: 800; } +.override-form fieldset label { display: grid; gap: 5px; color: #5f554d; font-size: 11px; font-weight: 700; } +.override-scope-choice { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; } +.override-scope-choice button { display: grid; gap: 3px; padding: 11px; border: 1px solid #d4c7b6 !important; border-radius: 5px; background: #fffdf8 !important; text-align: left; } +.override-scope-choice button.active { border-color: #72a88d !important; background: #edf6f0 !important; } +.override-scope-choice span { color: #756a61; font-size: 10px; line-height: 1.4; } +.override-warning { display: grid; gap: 3px; padding: 10px; border-left: 3px solid #c99035; background: #fff6e6; } +.override-warning span { color: #74685e; font-size: 11px; } +.application-form fieldset { margin: 0; padding: 12px; border: 1px solid #ded2c2; border-radius: 5px; } +.application-form legend { padding: 0 6px; color: #2d6652; font-weight: 800; } +.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.parameter-list { display: grid; gap: 9px; } +.parameter-row { display: grid; grid-template-columns: minmax(0, 1fr) 150px; gap: 9px; align-items: end; } +.parameter-row > label > span { color: #3c342e; } +.parameter-row small { color: #8a7e74; font-weight: 400; } +.selector-summary { margin: 10px 0 0; color: #796e64; font-size: 11px; } +.application-form > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 15px; border-top: 1px solid #ded2c2; } +.object-form > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 15px; border-top: 1px solid #ded2c2; } +.override-form > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 15px; border-top: 1px solid #ded2c2; } +.application-form > footer button, +.object-form > footer button, +.override-form > footer button, +.inspector-actions button { display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px; border: 1px solid #d2c4b3; border-radius: 4px; background: #fffdf8; } +.application-form > footer .primary, +.object-form > footer .primary { color: #fff; border-color: #1d7052; background: #1f7a58; } +.override-form > footer .primary { color: #fff; border-color: #1d7052; background: #1f7a58; } +.application-form > footer button:disabled { opacity: .45; } +.object-form > footer button:disabled { opacity: .45; } +.override-form > footer button:disabled { opacity: .45; } +.inspector-actions { display: flex; gap: 7px; margin: 10px 0 16px; } +.inspector-actions .danger { color: #a33d31; border-color: #d8a296; } + +.binding-form { width: min(680px, 100%); } +.binding-form > header div { display: grid; gap: 2px; } +.binding-form > header span { color: #786d64; font-size: 11px; } +.binding-form .overlay-content { display: grid; gap: 14px; } +.binding-route { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 14px; + padding: 12px; + border: 1px solid #d8cbbb; + border-radius: 5px; + background: #fffdf8; +} +.binding-route > div { display: grid; gap: 3px; min-width: 0; } +.binding-route > div:last-child { text-align: right; } +.binding-route small { color: #7c7168; text-transform: uppercase; font-size: 9px; } +.binding-route strong,.binding-route code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.binding-route code { color: #1f7053; } +.binding-form fieldset { margin: 0; padding: 12px; border: 1px solid #ded2c2; border-radius: 5px; } +.binding-form legend { padding: 0 6px; color: #2d6652; font-weight: 800; } +.binding-form fieldset label { display: grid; gap: 5px; color: #5f554d; font-size: 11px; font-weight: 700; } +.binding-form select { + width: 100%; + min-width: 0; + padding: 8px 9px; + border: 1px solid #d3c5b4; + border-radius: 4px; + background: #fffdf8; + color: #332c26; + font: 12px ui-monospace, monospace; +} +.binding-form > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 15px; border-top: 1px solid #ded2c2; } +.binding-form > footer button { display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px; border: 1px solid #d2c4b3; border-radius: 4px; background: #fffdf8; } +.binding-form > footer .primary { color: #fff; border-color: #1d7052; background: #1f7a58; } + +.cycle-break-dialog { width: min(620px, 100%); } +.cycle-break-dialog > header div { display: grid; gap: 2px; } +.cycle-break-dialog > header span { color: #a33d31; font: 11px ui-monospace, monospace; } +.cycle-break-dialog .overlay-content { display: grid; gap: 13px; } +.cycle-break-dialog p { margin: 0; line-height: 1.5; } +.cycle-impact { display: grid; gap: 3px; padding: 10px; border-left: 3px solid #c54c3c; background: #fff0eb; } +.cycle-impact span { color: #705f57; font-size: 11px; } +.cycle-break-dialog fieldset { margin: 0; padding: 12px; border: 1px solid #d8cbbb; border-radius: 5px; } +.cycle-break-dialog legend { padding: 0 6px; color: #9a392e; font-weight: 800; } +.cycle-break-dialog label { display: grid; gap: 5px; color: #5f554d; font-size: 11px; font-weight: 700; } +.cycle-break-dialog input,.cycle-break-dialog select { width: 100%; min-width: 0; padding: 8px 9px; border: 1px solid #d3c5b4; border-radius: 4px; background: #fffdf8; } +.cycle-break-dialog > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 15px; border-top: 1px solid #ded2c2; } +.cycle-break-dialog > footer button { display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px; border: 1px solid #d2c4b3; border-radius: 4px; background: #fffdf8; } +.cycle-break-dialog > footer .primary { color: #fff; border-color: #a8392d; background: #b94435; } +.cycle-break-dialog > footer button:disabled { opacity: .45; cursor: default; } + +@media (max-width: 700px) { + .form-grid { grid-template-columns: 1fr; } + .parameter-row { grid-template-columns: 1fr; } + .override-scope-choice { grid-template-columns: 1fr; } + .binding-route { grid-template-columns: 1fr; } + .binding-route > div:last-child { text-align: left; } + .initialization-value { grid-template-columns: 1fr; } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--ink); + background: + radial-gradient(circle at 20% 24%, rgba(31, 122, 83, 0.045), transparent 30%), + radial-gradient(circle at 78% 68%, rgba(201, 144, 53, 0.055), transparent 34%), + linear-gradient(180deg, rgba(255, 250, 242, 0.26), transparent 42%), + var(--bg); + font-family: "Avenir Next", "Trebuchet MS", "Segoe UI", sans-serif; +} + +.app-shell { + display: grid; + grid-template-columns: minmax(0, 1fr); + height: 100vh; + position: relative; +} + +.app-shell.has-side-panel { + grid-template-columns: minmax(0, 1fr) 340px; +} + +.graph-panel { + position: relative; + min-width: 0; +} + +.graph-panel::after { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 0.2; + background-image: + radial-gradient(rgba(49, 39, 33, 0.11) 0.55px, transparent 0.55px); + background-size: 16px 16px; + mask-image: linear-gradient(to bottom, transparent 0%, black 22%, black 82%, transparent 100%); +} + +.topbar { + position: absolute; + z-index: 10; + top: 18px; + left: 18px; + right: 18px; + display: flex; + align-items: center; + gap: 12px; + padding: 11px 14px; + background: rgba(255, 250, 242, 0.92); + border: 1px solid var(--line); + border-radius: 14px; + box-shadow: 0 18px 45px var(--shadow); + backdrop-filter: blur(10px); +} + +.topbar::before { + content: ""; + align-self: stretch; + width: 5px; + border-radius: 999px; + background: var(--accent); +} + +.editor-feedback { + position: absolute; + z-index: 12; + top: 96px; + left: 18px; + right: 18px; + padding: 9px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 250, 242, 0.95); + box-shadow: 0 12px 30px var(--shadow); + max-height: 132px; + overflow: auto; + font-size: 12px; + line-height: 1.45; + white-space: normal; + overflow-wrap: anywhere; +} + +.editor-feedback.error { + color: var(--clay); + border-color: rgba(191, 106, 84, 0.4); + background: rgba(255, 244, 238, 0.97); +} + +.editor-feedback.info { + color: var(--accent); + border-color: rgba(31, 122, 83, 0.35); + background: rgba(245, 252, 248, 0.97); +} + +.cycle-break-prompt { + position: absolute; + z-index: 14; + top: 98px; + left: 18px; + right: 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 11px 12px; + border: 1px solid rgba(211, 66, 47, 0.32); + border-radius: 12px; + background: + linear-gradient(135deg, rgba(211, 66, 47, 0.12), transparent 62%), + rgba(255, 250, 242, 0.96); + box-shadow: 0 16px 36px rgba(56, 43, 35, 0.16); +} + +.cycle-break-prompt div { + display: grid; + gap: 3px; + min-width: 0; +} + +.cycle-break-prompt strong { + color: #7a2018; + font-size: 13px; +} + +.cycle-break-prompt span { + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} + +.cycle-break-prompt code { + color: #7a2018; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +.cycle-break-prompt.active { + border-color: rgba(211, 66, 47, 0.52); + box-shadow: + 0 16px 36px rgba(56, 43, 35, 0.16), + 0 0 0 4px rgba(211, 66, 47, 0.08); +} + +.cycle-break-cta { + flex: 0 0 auto; + justify-content: center; + font-weight: 780; +} + +.graph-workbench { + flex-wrap: wrap; + align-items: flex-start; + max-height: min(36vh, 250px); + overflow: auto; + scrollbar-width: thin; +} + +.brand-block { + min-width: 150px; +} + +.brand-block h1 { + font-size: clamp(16px, 2.2vw, 20px); +} + +.search-box { + position: relative; + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 280px; + max-width: 460px; + min-width: 220px; + padding: 7px 9px; + border: 1px solid var(--line); + border-radius: 11px; + background: rgba(255, 253, 247, 0.86); +} + +.search-box input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + color: var(--ink); + background: transparent; + font: inherit; + font-size: 13px; +} + +.clear-search { + display: grid; + place-items: center; + width: 20px; + height: 20px; + border: 0; + border-radius: 999px; + background: rgba(183, 166, 150, 0.18); + color: var(--muted); + cursor: pointer; +} + +.search-results { + position: absolute; + z-index: 80; + top: calc(100% + 8px); + left: 0; + right: 0; + display: grid; + gap: 6px; + max-height: 360px; + overflow: auto; + padding: 8px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 250, 242, 0.98); + box-shadow: 0 18px 45px var(--shadow); +} + +.search-result { + display: grid; + gap: 2px; + padding: 8px 9px; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--ink); + text-align: left; + cursor: pointer; +} + +.search-result:hover, +.search-result:focus-visible { + border-color: rgba(31, 122, 83, 0.24); + background: var(--accent-soft); + outline: none; +} + +.search-result strong { + overflow-wrap: anywhere; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 12px; +} + +.search-result span { + color: var(--muted); + font-size: 11px; +} + +.eyebrow { + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +h1, +h2, +h3 { + margin: 0; + letter-spacing: 0; +} + +h1 { + font-size: 20px; + font-weight: 800; +} + +.metrics { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-left: auto; +} + +.metrics span, +.metric-button, +.node-meta span { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 8px; + border: 1px solid var(--line); + border-radius: 9px; + background: rgba(255, 250, 242, 0.9); + font-size: 12px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +.metric-button { + color: var(--ink); + cursor: pointer; +} + +.metric-button.active, +.metric-button:hover, +.metric-button:focus-visible { + outline: none; + background: #fff6f0; +} + +.view-mode-button.overview-cta { + border-color: rgba(31, 122, 83, 0.38); + color: var(--accent); + background: rgba(31, 122, 83, 0.1); + box-shadow: 0 8px 18px rgba(31, 122, 83, 0.1); + font-weight: 800; +} + +.meta-chip { + position: relative; +} + +.meta-chip::after { + content: attr(data-tooltip); + position: absolute; + z-index: 20; + left: 0; + bottom: calc(100% + 10px); + width: max-content; + max-width: 280px; + padding: 8px 10px; + color: var(--paper); + background: var(--ink); + border-radius: 10px; + box-shadow: 0 12px 28px rgba(56, 43, 35, 0.18); + font-family: "Avenir Next", "Trebuchet MS", "Segoe UI", sans-serif; + font-size: 12px; + line-height: 1.3; + white-space: normal; + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: opacity 120ms ease, transform 120ms ease; +} + +.meta-chip::before { + content: ""; + position: absolute; + z-index: 21; + left: 16px; + bottom: calc(100% + 4px); + width: 10px; + height: 10px; + background: var(--ink); + opacity: 0; + pointer-events: none; + transform: rotate(45deg) translateY(4px); + transition: opacity 120ms ease, transform 120ms ease; +} + +.meta-chip:hover::after, +.meta-chip:hover::before, +.meta-chip:focus-visible::after, +.meta-chip:focus-visible::before { + opacity: 1; + transform: translateY(0); +} + +.meta-chip:hover::before, +.meta-chip:focus-visible::before { + transform: rotate(45deg) translateY(0); +} + +.metrics .warn { + color: var(--clay); + border-color: rgba(201, 97, 74, 0.45); +} + +.metrics .caution { + color: var(--ochre); + border-color: rgba(201, 144, 53, 0.45); +} + +.metrics .warn svg { + flex: 0 0 auto; +} + +.icon-button { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--paper); + color: var(--ink); + cursor: pointer; + box-shadow: 0 8px 18px rgba(56, 43, 35, 0.08); +} + +.icon-button.compact { + width: 28px; + height: 28px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 14px; +} + +.toolbar-group { + display: flex; + align-items: center; + gap: 8px; +} + +.open-button { + flex: 0 0 auto; +} + +.panel-switch { + flex-wrap: wrap; +} + +.select-control { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 250, 242, 0.9); + color: var(--muted); +} + +.select-control select { + max-width: 132px; + border: 0; + outline: 0; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 12px; +} + +.relationship-legend { + position: absolute; + z-index: 35; + top: 116px; + left: 18px; + display: grid; + gap: 7px; + width: 190px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 13px; + background: rgba(255, 250, 242, 0.92); + box-shadow: 0 18px 45px var(--shadow); + backdrop-filter: blur(10px); + max-height: min(480px, calc(100vh - 142px)); + overflow: auto; +} + +.legend-title, +.legend-note { + display: flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 11px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +.relationship-legend button { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 7px; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.relationship-legend button.active { + color: var(--ink); + border-color: rgba(31, 122, 83, 0.22); + background: var(--accent-soft); +} + +.legend-line { + width: 28px; + height: 0; + border-top: 2px solid var(--line-strong); +} + +.legend-line.mapped { + border-color: var(--accent); + border-style: dashed; +} + +.legend-line.call { + border-color: var(--clay); + border-style: dashed; +} + +.scale-controls { + position: absolute; + z-index: 35; + top: 116px; + left: 220px; + display: grid; + gap: 8px; + width: 190px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 13px; + background: rgba(255, 250, 242, 0.92); + box-shadow: 0 18px 45px var(--shadow); + backdrop-filter: blur(10px); + max-height: min(560px, calc(100vh - 142px)); + overflow: auto; +} + +.scale-list { + display: grid; + gap: 6px; +} + +.scale-list button, +.scale-reset { + display: grid; + gap: 2px; + width: 100%; + padding: 7px 8px; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--muted); + font: inherit; + text-align: left; + cursor: pointer; +} + +.scale-list button.active { + color: var(--ink); + border-color: rgba(31, 122, 83, 0.22); + background: var(--accent-soft); +} + +.scale-list button.collapsed { + border-color: rgba(183, 166, 150, 0.34); + border-style: dashed; + background: rgba(183, 166, 150, 0.08); +} + +.scale-list span { + overflow-wrap: anywhere; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 12px; + font-weight: 700; +} + +.scale-list small { + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.scale-reset { + color: var(--accent); + border-color: rgba(31, 122, 83, 0.2); + background: rgba(31, 122, 83, 0.08); + text-align: center; +} + +.floating-panel { + position: absolute; + z-index: 40; + top: 116px; + right: 18px; + width: min(360px, calc(100vw - 36px)); + max-height: min(560px, calc(100vh - 142px)); + overflow: auto; + padding: 14px; + background: rgba(255, 250, 242, 0.96); + border: 1px solid rgba(191, 106, 84, 0.32); + border-radius: 14px; + box-shadow: 0 18px 45px var(--shadow); + backdrop-filter: blur(12px); +} + +.warnings-panel { + border-color: rgba(201, 144, 53, 0.35); +} + +.open-panel { + left: 18px; + right: auto; + border-color: rgba(31, 122, 83, 0.32); +} + +.floating-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.floating-panel h2 { + font-size: 18px; +} + +.react-flow { + background: transparent; + z-index: 1; +} + +.react-flow__background { + display: none; +} + +.react-flow__edges { + z-index: 8; +} + +.react-flow__edges:has(.react-flow__edge.highlighted) { + z-index: 120; +} + +.react-flow__edgelabel-renderer { + z-index: 28; +} + +.react-flow__edgelabel-renderer:has(.edge-chip.highlighted) { + z-index: 121; +} + +.react-flow__nodes { + z-index: 20; +} + +.model-node { + position: relative; + overflow: visible; + background: rgba(255, 250, 242, 0.96); + border: 1px solid var(--line); + border-radius: 16px; + box-shadow: 0 18px 42px var(--shadow); +} + +.model-remove-button { + position: absolute; + z-index: 45; + top: -11px; + right: -11px; + display: grid; + place-items: center; + width: 30px; + height: 30px; + border: 1px solid rgba(191, 106, 84, 0.4); + border-radius: 999px; + color: #fff; + background: var(--clay); + box-shadow: 0 10px 24px rgba(191, 106, 84, 0.24); + cursor: pointer; +} + +.model-remove-button:hover, +.model-remove-button:focus-visible { + outline: none; + background: #9b3f2e; + transform: translateY(-1px); +} + +.node-header { + border-radius: 16px 16px 0 0; +} + +.model-node.selected { + border-color: rgba(31, 122, 83, 0.72); + box-shadow: 0 20px 48px rgba(31, 122, 83, 0.14); +} + +.model-node.focused { + border-color: rgba(31, 122, 83, 0.62); + box-shadow: + 0 20px 48px rgba(31, 122, 83, 0.14), + 0 0 0 4px rgba(31, 122, 83, 0.08); +} + +.model-node.dimmed { + opacity: 0.2; + filter: grayscale(0.2); +} + +.model-node.cyclic { + border-color: rgba(191, 106, 84, 0.62); + box-shadow: + 0 18px 42px var(--shadow), + 0 0 0 3px rgba(191, 106, 84, 0.08); +} + +.model-node.cyclic .node-header > svg { + color: var(--clay); +} + +.model-node.hard_dependency { + border-color: rgba(191, 106, 84, 0.55); + border-style: dashed; + background: rgba(255, 246, 240, 0.94); + box-shadow: 0 14px 30px rgba(191, 106, 84, 0.12); +} + +.model-node.hard_dependency .node-header { + background: + linear-gradient(90deg, rgba(191, 106, 84, 0.08), transparent 60%), + var(--paper-strong); +} + +.model-node.hard_dependency .node-header > svg { + color: var(--clay); +} + +.model-node.hard_dependency .process::before { + content: "↳ "; + color: var(--clay); +} + +.model-node.overview-node { + border-radius: 12px; + box-shadow: 0 10px 24px rgba(56, 43, 35, 0.1); +} + +.model-node.overview-node .node-header { + min-height: 64px; + padding: 9px 10px; + border-radius: 12px 12px 0 0; +} + +.model-node.overview-node .process { + font-size: 12px; + line-height: 1.18; + overflow-wrap: anywhere; +} + +.model-node.overview-node .model-type { + font-size: 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.overview-node-summary { + display: flex; + flex-wrap: wrap; + gap: 5px; + padding: 8px 10px 10px; +} + +.overview-node-summary span { + min-width: 0; + max-width: 100%; + padding: 3px 6px; + border: 1px solid rgba(183, 166, 150, 0.35); + border-radius: 999px; + color: var(--muted); + background: rgba(255, 253, 247, 0.76); + font-size: 10px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.overview-port-handles .react-flow__handle { + width: 7px; + height: 7px; + border-color: rgba(255, 250, 242, 0.9); + background: var(--line-strong); +} + +.overview-port-handles .react-flow__handle-right { + background: var(--accent); +} + +.hard-chip { + color: var(--clay); + border-color: rgba(191, 106, 84, 0.28) !important; + background: rgba(255, 246, 240, 0.9) !important; +} + +.node-header { + display: grid; + grid-template-columns: 1fr auto; + align-items: flex-start; + gap: 11px; + padding: 11px 12px 12px; + color: var(--ink); + background: var(--paper-strong); + border-bottom: 1px solid var(--line); +} + +.node-header > svg { + color: var(--accent); +} + +.process { + font-weight: 820; + font-size: 15px; + letter-spacing: 0; +} + +.model-type { + margin-top: 2px; + font-size: 12px; + color: var(--muted); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +.node-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 12px 0; +} + +.ports-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + padding: 12px; +} + +.port-title { + margin-bottom: 6px; + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +.port { + position: relative; + display: flex; + align-items: center; + gap: 5px; + min-height: 24px; + margin: 4px 0; + padding: 4px 7px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 253, 247, 0.9); + font-size: 12px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +.port::after { + content: attr(data-default); + position: absolute; + z-index: 60; + left: 50%; + bottom: calc(100% + 8px); + width: max-content; + max-width: 240px; + padding: 7px 9px; + color: var(--paper); + background: var(--ink); + border-radius: 9px; + box-shadow: 0 12px 28px rgba(56, 43, 35, 0.18); + font-family: "Avenir Next", "Trebuchet MS", "Segoe UI", sans-serif; + font-size: 11px; + line-height: 1.3; + white-space: normal; + opacity: 0; + pointer-events: none; + transform: translate(-50%, 4px); + transition: opacity 120ms ease, transform 120ms ease; +} + +.port:hover::after, +.port.active::after { + opacity: 1; + transform: translate(-50%, 0); +} + +.app-shell.has-candidate-popover .port.active::after { + opacity: 0; +} + +.port.output { + justify-content: flex-end; +} + +.port.previous { + color: var(--clay); +} + +.port.mapped { + border-color: rgba(31, 122, 83, 0.38); +} + +.port-candidate-button { + position: relative; + z-index: 8; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 16px; + height: 16px; + padding: 0; + color: var(--accent); + background: rgba(255, 253, 247, 0.94); + border: 1px solid rgba(31, 122, 83, 0.34); + border-radius: 999px; + cursor: pointer; +} + +.port-candidate-button:hover, +.port-candidate-button:focus-visible { + color: #fffdfa; + background: var(--accent); + outline: none; +} + +.port-cycle-break-button { + position: relative; + z-index: 9; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 18px; + height: 18px; + padding: 0; + color: #9b2d22; + background: rgba(255, 241, 236, 0.96); + border: 1px solid rgba(211, 66, 47, 0.45); + border-radius: 999px; + cursor: pointer; + box-shadow: 0 5px 12px rgba(211, 66, 47, 0.16); +} + +.port-cycle-break-button:hover, +.port-cycle-break-button:focus-visible { + color: #fffdfa; + background: #d3422f; + outline: none; +} + +.port.active .port-candidate-button { + color: var(--accent); + background: #fffdfa; +} + +@keyframes cycleTargetPulse { + 0%, + 100% { + box-shadow: + inset 3px 0 0 rgba(211, 66, 47, 0.78), + 0 0 0 3px rgba(211, 66, 47, 0.08); + } + 50% { + box-shadow: + inset 3px 0 0 rgba(211, 66, 47, 0.92), + 0 0 0 6px rgba(211, 66, 47, 0.15); + } +} + +.candidate-popover { + position: fixed; + z-index: 1200; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + color: var(--ink); + border: 1px solid rgba(31, 122, 83, 0.28); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(31, 122, 83, 0.08), transparent 42%), + rgba(255, 253, 247, 0.98); + box-shadow: 0 18px 42px rgba(56, 43, 35, 0.18); +} + +.candidate-popover-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px 12px 9px; + border-bottom: 1px solid rgba(183, 166, 150, 0.32); +} + +.candidate-popover-header h3 { + margin: 3px 0 0; + overflow-wrap: anywhere; + font-size: 15px; +} + +.candidate-popover-list { + display: grid; + gap: 8px; + overflow: auto; + padding: 10px; +} + +.candidate-model-card { + display: grid; + gap: 5px; + width: 100%; + padding: 9px 10px; + color: var(--ink); + border: 1px solid rgba(183, 166, 150, 0.38); + border-left: 4px solid var(--accent); + border-radius: 8px; + background: rgba(255, 255, 255, 0.7); + font: inherit; + text-align: left; + cursor: pointer; +} + +.candidate-model-card:hover, +.candidate-model-card:focus-visible { + background: rgba(255, 253, 247, 0.96); + border-color: rgba(31, 122, 83, 0.42); + outline: none; +} + +.candidate-model-card strong { + overflow-wrap: anywhere; + font-size: 12px; +} + +.candidate-model-card span { + color: var(--muted); + font-size: 11px; +} + +.candidate-model-card small { + overflow-wrap: anywhere; + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.port.required-input { + border-color: rgba(191, 106, 84, 0.72); + background: + linear-gradient(90deg, rgba(191, 106, 84, 0.16), rgba(255, 253, 247, 0.92) 62%), + rgba(255, 253, 247, 0.9); + box-shadow: inset 3px 0 0 rgba(191, 106, 84, 0.74); +} + +.port.required-input .react-flow__handle { + border-color: var(--clay); + background: #fff6f0; + box-shadow: + 0 0 0 3px rgba(255, 250, 242, 0.92), + 0 0 0 6px rgba(191, 106, 84, 0.13); +} + +.port.cycle-break-target { + border-color: rgba(211, 66, 47, 0.72); + background: + linear-gradient(90deg, rgba(211, 66, 47, 0.18), rgba(255, 253, 247, 0.92) 62%), + rgba(255, 253, 247, 0.9); + box-shadow: + inset 3px 0 0 rgba(211, 66, 47, 0.78), + 0 0 0 3px rgba(211, 66, 47, 0.08); +} + +.cycle-break-mode .port.cycle-break-target { + animation: cycleTargetPulse 1.35s ease-in-out infinite; +} + +.port.cycle-break-target .react-flow__handle { + border-color: #d3422f; + background: #fff1ec; + box-shadow: + 0 0 0 3px rgba(255, 250, 242, 0.92), + 0 0 0 7px rgba(211, 66, 47, 0.15); +} + +.port.highlighted { + border-color: var(--line); + background: #fffdfa; +} + +.port.focused { + border-color: rgba(31, 122, 83, 0.58); + background: var(--accent-soft); +} + +.port.active { + color: #fffdfa; + border-color: var(--accent); + background: var(--accent); +} + +.port.cycle-break-target.active, +.port.cycle-break-target.focused, +.port.cycle-break-target.highlighted { + color: #7a2018; + border-color: rgba(211, 66, 47, 0.78); + background: + linear-gradient(90deg, rgba(211, 66, 47, 0.2), rgba(255, 253, 247, 0.94) 64%), + rgba(255, 253, 247, 0.94); + box-shadow: + inset 3px 0 0 rgba(211, 66, 47, 0.86), + 0 0 0 4px rgba(211, 66, 47, 0.12); +} + +.port.cycle-break-target.active::after { + background: #7a2018; +} + +.port.cycle-break-target.active .port-cycle-break-button, +.port.cycle-break-target.focused .port-cycle-break-button, +.port.cycle-break-target.highlighted .port-cycle-break-button { + color: #9b2d22; + background: rgba(255, 241, 236, 0.98); +} + +.react-flow__handle { + width: 9px; + height: 9px; + border: 1px solid var(--line-strong); + background: var(--paper); + z-index: 25; + box-shadow: 0 0 0 3px rgba(255, 250, 242, 0.92); +} + +.react-flow__handle.call-handle { + width: 12px; + height: 36px; + opacity: 0; + pointer-events: none; +} + +.react-flow__edge.multiscale path { + stroke-dasharray: 7 5; +} + +.react-flow__edge.mapped_variable path { + stroke: var(--accent); +} + +.react-flow__edge.hard_dependency path { + stroke: var(--clay); +} + +.react-flow__edge.cycle_dependency path, +.react-flow__edge.cycle_edge path { + stroke: #d3422f; + filter: drop-shadow(0 0 5px rgba(211, 66, 47, 0.26)); +} + +.react-flow__edge.call_edge path { + stroke-width: 1.7; + stroke-dasharray: 3 6; + opacity: 0.7; +} + +.react-flow__edge.highlighted path { + stroke-width: 3; + stroke: var(--accent); +} + +.react-flow__edge.focused path { + stroke-width: 2.8; +} + +.react-flow__edge.highlighted { + z-index: 80 !important; +} + +.react-flow__edge.focused { + z-index: 70 !important; +} + +.react-flow__edge.dimmed { + opacity: 0.04; +} + +.overview-mode .react-flow__edge.variable_edge path { + stroke-width: 1.45 !important; + opacity: 0.74; +} + +.overview-mode .react-flow__edge.call_edge path { + stroke-width: 1.15 !important; + opacity: 0.4; +} + +.overview-mode .react-flow__edge.highlighted path, +.overview-mode .react-flow__edge.focused path { + stroke-width: 2.8 !important; + opacity: 1; +} + +.edge-chip { + position: absolute; + z-index: -1; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 7px; + color: var(--ink); + background: rgba(255, 250, 242, 0.94); + border: 1px solid var(--line); + border-radius: 999px; + box-shadow: 0 8px 20px rgba(56, 43, 35, 0.1); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 11px; + line-height: 1; + pointer-events: none; + white-space: nowrap; +} + +.edge-chip.mapped_variable, +.edge-chip.multiscale { + border-color: rgba(31, 122, 83, 0.3); + background: rgba(248, 250, 241, 0.96); +} + +.edge-chip.hard_dependency { + border-color: rgba(191, 106, 84, 0.32); + background: rgba(255, 246, 240, 0.96); +} + +.edge-chip.cycle_dependency, +.edge-chip.cycle_edge { + color: #7a2018; + border-color: rgba(211, 66, 47, 0.44); + background: rgba(255, 238, 233, 0.98); +} + +.edge-chip small { + color: var(--muted); + font-size: 9px; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.edge-chip.highlighted { + z-index: 80; + color: #fffdfa; + border-color: var(--accent); + background: var(--accent); + box-shadow: 0 12px 24px rgba(31, 122, 83, 0.22); +} + +.edge-chip.highlighted small { + color: rgba(255, 253, 247, 0.72); +} + +.edge-chip.dimmed { + opacity: 0.04; +} + +.overview-mode .edge-chip:not(.highlighted):not(.focused), +.overview-mode .edge-terminal:not(.highlighted):not(.focused) { + display: none; +} + +.edge-chip.focused { + z-index: 70; + border-color: rgba(31, 122, 83, 0.36); + box-shadow: 0 10px 22px rgba(31, 122, 83, 0.14); +} + +.edge-terminal { + position: absolute; + z-index: 32; + --terminal-color: var(--line-strong); + width: 18px; + height: 10px; + pointer-events: none; + opacity: 0.95; +} + +.edge-terminal::before { + content: ""; + position: absolute; + top: 4px; + left: 2px; + right: 2px; + height: 2px; + border-radius: 999px; + background: var(--terminal-color); +} + +.edge-terminal.target::after { + content: ""; + position: absolute; + top: 1px; + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; +} + +.edge-terminal[data-side="left"]::before { + left: 7px; +} + +.edge-terminal[data-side="right"]::before { + right: 7px; +} + +.edge-terminal.target[data-side="left"]::after { + right: 0; + border-left: 7px solid var(--terminal-color); +} + +.edge-terminal.target[data-side="right"]::after { + left: 0; + border-right: 7px solid var(--terminal-color); +} + +.edge-terminal.highlighted::before { + height: 3px; +} + +.edge-terminal.dimmed { + opacity: 0.12; +} + +.cycle-edge-card { + border-color: rgba(211, 66, 47, 0.34); + background: + linear-gradient(135deg, rgba(211, 66, 47, 0.1), transparent 58%), + rgba(255, 250, 242, 0.88); +} + +.cycle-break-button { + justify-content: center; + margin: 8px 0 4px; +} + +.inspector { + border-left: 1px solid var(--line); + background: rgba(255, 250, 242, 0.82); + backdrop-filter: blur(14px); + padding: 18px; + overflow: auto; +} + +.inspector:focus { + outline: none; +} + +.inspector.guided-focus { + animation: guided-panel-focus 1.8s ease-out; +} + +@keyframes guided-panel-focus { + 0% { + box-shadow: inset 0 0 0 3px rgba(31, 122, 83, 0.48), -18px 0 44px rgba(31, 122, 83, 0.16); + background: rgba(250, 255, 247, 0.94); + } + 60% { + box-shadow: inset 0 0 0 3px rgba(31, 122, 83, 0.36), -12px 0 34px rgba(31, 122, 83, 0.12); + } + 100% { + box-shadow: none; + background: rgba(255, 250, 242, 0.82); + } +} + +.inspector header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; +} + +.mapping-code-panel { + display: grid; + gap: 10px; +} + +.row-with-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.mapping-code { + width: 100%; + min-height: 260px; + resize: vertical; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 253, 247, 0.9); + color: var(--ink); + padding: 10px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 12px; + line-height: 1.45; +} + +.storage-grid { + display: grid; + gap: 8px; +} + +.path-status { + display: grid; + gap: 3px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 250, 242, 0.72); + padding: 9px; +} + +.path-status span { + color: var(--muted); + font-size: 11px; + font-weight: 720; +} + +.path-status strong { + color: var(--ink); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 11px; + overflow-wrap: anywhere; +} + +.recent-mappings { + display: grid; + gap: 8px; + margin-top: 4px; +} + +.open-mapping-panel { + display: grid; + gap: 12px; +} + +.recent-mapping-list { + display: grid; + gap: 7px; +} + +.recent-mapping-item { + display: grid; + gap: 3px; + text-align: left; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 253, 247, 0.82); + color: var(--ink); + padding: 9px; + cursor: pointer; +} + +.recent-mapping-item:hover, +.recent-mapping-item:focus-visible { + border-color: rgba(31, 122, 83, 0.32); + background: rgba(31, 122, 83, 0.08); +} + +.recent-mapping-item span { + font-weight: 720; +} + +.recent-mapping-item small { + color: var(--muted); + overflow-wrap: anywhere; +} + +.inline-field { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + +.inspector h2 { + font-size: 17px; +} + +.inspector h3 { + margin-top: 22px; + margin-bottom: 8px; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.row { + display: grid; + grid-template-columns: 84px minmax(0, 1fr); + gap: 8px; + padding: 8px 0; + border-top: 1px solid rgba(183, 166, 150, 0.35); +} + +.row span { + color: var(--muted); +} + +.row strong { + overflow-wrap: anywhere; + font-weight: 620; +} + +.variable-card { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 250, 242, 0.75); + padding: 10px; +} + +.edge-detail-card { + display: grid; + gap: 4px; + margin-bottom: 14px; + padding: 10px; + border: 1px solid rgba(31, 122, 83, 0.24); + border-radius: 12px; + background: + linear-gradient(135deg, rgba(31, 122, 83, 0.08), transparent 58%), + rgba(255, 250, 242, 0.82); +} + +.edge-detail-card .diagnostic, +.edge-detail-card .empty-state { + margin-top: 6px; +} + +.variable-card .row:first-of-type { + margin-top: 8px; +} + +.variable-card-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +.variable-card-title span { + overflow-wrap: anywhere; + font-weight: 720; +} + +.variable-card-title small { + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.diagnostic, +.edit-suggestion, +.initialization-note, +.empty-state { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 250, 242, 0.75); + padding: 10px; + color: var(--muted); +} + +.diagnostic, +.edit-suggestion, +.initialization-note { + display: flex; + align-items: center; + gap: 7px; +} + +.diagnostic, +.edit-suggestion { + color: var(--clay); + border-color: rgba(201, 97, 74, 0.28); + background: rgba(201, 97, 74, 0.09); +} + +.initialization-note { + color: #87533b; + border-color: rgba(191, 106, 84, 0.32); + background: rgba(191, 106, 84, 0.1); +} + +.initialization-list { + display: grid; + gap: 8px; +} + +.initialization-list.compact { + gap: 6px; +} + +.initialization-group { + display: grid; + gap: 7px; +} + +.initialization-group h4, +.provenance-block h4 { + margin: 6px 0 0; + color: var(--muted); + font-size: 11px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-weight: 700; +} + +.initialization-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + padding: 9px 10px; + color: var(--ink); + background: rgba(255, 250, 242, 0.78); + border: 1px solid rgba(191, 106, 84, 0.28); + border-left: 4px solid var(--clay); + border-radius: 10px; + font: inherit; + text-align: left; + cursor: pointer; +} + +.initialization-item:hover, +.initialization-item:focus-visible { + background: rgba(255, 246, 240, 0.96); + outline: none; +} + +.initialization-item span { + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.initialization-item strong { + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 12px; +} + +.initialization-item small { + grid-column: 1 / -1; + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.initialization-item.previous_time_step { + border-left-color: var(--ochre); +} + +.initialization-item.mapped_unresolved { + border-left-color: var(--accent); +} + +.warning-list, +.provenance-block { + display: grid; + gap: 8px; +} + +.warning-group { + display: grid; + gap: 7px; +} + +.warning-group h4 { + margin: 6px 0 0; + color: var(--muted); + font-size: 11px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-weight: 700; +} + +.warning-item, +.provenance-edge { + display: grid; + gap: 3px; + width: 100%; + padding: 9px 10px; + border: 1px solid rgba(201, 144, 53, 0.28); + border-left: 4px solid var(--ochre); + border-radius: 10px; + background: rgba(255, 250, 242, 0.78); + color: var(--ink); + font: inherit; + text-align: left; + cursor: pointer; +} + +.warning-item.error { + border-color: rgba(191, 106, 84, 0.34); + border-left-color: var(--clay); + background: rgba(191, 106, 84, 0.08); +} + +.warning-item.info { + border-color: rgba(127, 143, 115, 0.26); + border-left-color: var(--sage); + background: rgba(127, 143, 115, 0.08); +} + +.provenance-edge { + border-color: rgba(183, 166, 150, 0.42); + border-left-color: var(--line-strong); +} + +.provenance-edge.mapped_variable { + border-left-color: var(--accent); +} + +.provenance-edge.hard_dependency { + border-left-color: var(--clay); +} + +.warning-item:hover, +.warning-item:focus-visible, +.provenance-edge:hover, +.provenance-edge:focus-visible { + background: rgba(255, 246, 240, 0.96); + outline: none; +} + +.warning-item strong, +.provenance-edge strong { + overflow-wrap: anywhere; + font-size: 12px; +} + +.warning-item span, +.provenance-edge span, +.provenance-edge small { + color: var(--muted); + font-size: 11px; + line-height: 1.25; +} + +.provenance-block { + margin-top: 10px; +} + +.empty-state.compact { + padding: 7px 8px; + font-size: 11px; +} + +.live-session { + border-left: 1px solid rgba(183, 166, 150, 0.36); + padding-left: 10px; +} + +.live-pill { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 9px; + border: 1px solid rgba(191, 106, 84, 0.38); + border-radius: 999px; + color: var(--clay); + background: rgba(191, 106, 84, 0.08); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.live-pill.connected { + border-color: rgba(31, 122, 83, 0.34); + color: var(--accent); + background: rgba(31, 122, 83, 0.09); +} + +.metric-button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.model-browser { + display: grid; + gap: 7px; +} + +.model-browser-control { + display: grid; + gap: 4px; +} + +.model-browser-control span, +.parameter-row label { + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.model-browser-control select, +.model-browser-control input, +.parameter-row input, +.parameter-row select { + min-width: 0; + border: 1px solid rgba(183, 166, 150, 0.44); + border-radius: 8px; + background: rgba(255, 255, 255, 0.78); + color: var(--ink); + font: inherit; +} + +.model-browser-control select, +.model-browser-control input { + height: 32px; + padding: 0 8px; +} + +.model-browser-item { + display: grid; + gap: 7px; + padding: 8px 9px; + border: 1px solid rgba(183, 166, 150, 0.34); + border-radius: 10px; + background: rgba(255, 255, 255, 0.62); +} + +.model-browser-item.add-model-config { + gap: 10px; + padding-bottom: 0; +} + +.model-browser-title { + display: grid; + gap: 2px; +} + +.model-browser-item strong { + overflow-wrap: anywhere; + font-size: 12px; +} + +.model-browser-item span { + color: var(--muted); + font-size: 11px; +} + +.existing-model-editor { + display: grid; + gap: 8px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid rgba(183, 166, 150, 0.32); +} + +.variable-mapping-editor { + display: grid; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(183, 166, 150, 0.32); +} + +.variable-mapping-editor h4 { + margin: 0; + color: var(--muted); + font-size: 11px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-weight: 700; +} + +.initialization-editor { + display: grid; + gap: 12px; +} + +.initialization-editor-group { + display: grid; + gap: 8px; +} + +.initialization-editor-group h3 { + margin: 0; +} + +.initialization-editor-row { + display: grid; + grid-template-columns: minmax(0, 0.85fr) minmax(0, 1fr) minmax(82px, 0.72fr) auto; + gap: 7px; + align-items: center; + padding: 9px; + border: 1px solid rgba(191, 106, 84, 0.26); + border-left: 4px solid var(--clay); + border-radius: 8px; + background: rgba(255, 250, 242, 0.78); +} + +.initialization-editor-row.provided { + border-color: rgba(31, 122, 83, 0.24); + border-left-color: var(--accent); +} + +.initialization-editor-row label { + overflow-wrap: anywhere; + font-weight: 700; +} + +.initialization-editor-row input, +.initialization-editor-row select { + min-width: 0; + height: 30px; + border: 1px solid rgba(183, 166, 150, 0.44); + border-radius: 8px; + background: rgba(255, 255, 255, 0.78); + color: var(--ink); + font: inherit; +} + +.initialization-editor-row input { + padding: 0 8px; +} + +.initialization-editor-row small { + grid-column: 1 / -1; + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.parameter-row { + display: grid; + grid-template-columns: minmax(0, 0.75fr) minmax(0, 1fr) minmax(78px, 0.8fr); + gap: 6px; + align-items: center; +} + +.parameter-row input, +.parameter-row select { + height: 28px; + padding: 0 7px; + font-size: 12px; +} + +.rate-editor { + display: grid; + gap: 6px; + padding: 7px; + border: 1px solid rgba(183, 166, 150, 0.28); + border-radius: 8px; + background: rgba(252, 249, 244, 0.72); +} + +.add-model-footer { + position: sticky; + bottom: -18px; + display: flex; + justify-content: flex-end; + margin: 2px -9px 0; + padding: 10px 9px; + border-top: 1px solid rgba(183, 166, 150, 0.34); + border-radius: 0 0 10px 10px; + background: rgba(255, 250, 242, 0.94); + backdrop-filter: blur(8px); +} + +.rate-summary { + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + +.rate-clock-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.rate-clock-row label { + display: grid; + gap: 4px; + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.rate-clock-row input { + min-width: 0; + height: 28px; + border: 1px solid rgba(183, 166, 150, 0.44); + border-radius: 8px; + background: rgba(255, 255, 255, 0.78); + color: var(--ink); + font: inherit; + padding: 0 7px; +} + +.metric-button.danger { + border-color: rgba(191, 106, 84, 0.42); + color: #9b3f2e; + background: rgba(255, 242, 236, 0.86); +} + +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + } + + .inspector { + position: absolute; + z-index: 28; + top: 150px; + right: 16px; + bottom: 16px; + width: min(360px, calc(100% - 32px)); + border: 1px solid var(--line); + border-radius: 14px; + box-shadow: 0 18px 45px var(--shadow); + } + + .relationship-legend, + .scale-controls, + .floating-panel { + top: 150px; + } + + .scale-controls { + left: 220px; + } +} + +@media (max-width: 720px) { + .topbar { + top: 10px; + left: 10px; + right: 10px; + gap: 8px; + padding: 9px 10px; + } + + .topbar::before { + display: none; + } + + .brand-block { + min-width: 128px; + } + + .search-box { + flex-basis: 100%; + max-width: none; + min-width: 0; + } + + .metrics { + margin-left: 0; + } + + .relationship-legend, + .scale-controls, + .floating-panel { + top: 132px; + left: 10px; + right: 10px; + width: auto; + } + + .scale-controls { + top: 326px; + } + + .react-flow__minimap { + display: none; + } +} + +/* Mapping dialog */ +.mapping-dialog-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(49, 39, 33, 0.38); + display: flex; + align-items: center; + justify-content: center; +} + +.mapping-dialog { + background: var(--paper); + border: 1px solid var(--line-strong); + border-radius: 10px; + box-shadow: 0 8px 32px var(--shadow); + width: 360px; + max-width: calc(100vw - 32px); + display: flex; + flex-direction: column; + gap: 0; + overflow: hidden; +} + +.mapping-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 10px; + border-bottom: 1px solid var(--line); +} + +.mapping-dialog-header .eyebrow { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted); +} + +.mapping-dialog-body { + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.mapping-port-summary { + display: flex; + align-items: center; + gap: 8px; + background: var(--paper-strong); + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px 12px; +} + +.mapping-port { + display: flex; + flex-direction: column; + gap: 1px; + flex: 1; + min-width: 0; +} + +.mapping-port small { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); +} + +.mapping-port strong { + font-size: 12px; + font-weight: 700; + color: var(--accent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mapping-port span { + font-size: 11px; + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mapping-port.target strong { + color: var(--clay); +} + +.mapping-arrow { + font-size: 18px; + color: var(--muted); + flex-shrink: 0; +} + +.mapping-mode-section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mapping-mode-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 2px; +} + +.mapping-radio { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; + padding: 4px 0; +} + +.mapping-radio input[type="radio"] { + accent-color: var(--accent); + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.mapping-scale-picker { + display: flex; + flex-direction: column; + gap: 4px; +} + +.mapping-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; + padding: 2px 0; +} + +.mapping-checkbox input[type="checkbox"] { + accent-color: var(--accent); + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.mapping-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 16px 14px; + border-top: 1px solid var(--line); +} + +.accent-button { + background: var(--accent); + color: #fff; + border-color: transparent; +} + +.accent-button:hover:not(:disabled) { + background: var(--sage-dark); + border-color: transparent; +} +.selector-preview { + display: grid; + gap: 6px; + padding: 12px; + border: 1px solid #b9d3c4; + background: #f2f8f4; + border-radius: 6px; +} + +.selector-preview span, +.selector-preview p { + margin: 0; + color: #615c55; + font-size: 0.86rem; +} + +.selector-preview p { + color: #a44b39; +} + +.selector-preview-button { + display: inline-flex; + align-items: center; + gap: 6px; + width: max-content; +} + +.environment-ports { + border-top: 1px solid #ded5c8; + margin-top: 8px; + padding-top: 8px; +} + +.entity-node.environment { + border-color: #8fb9c2; + background: #f3fafb; +} + +.application-configuration-form { width: min(820px, 100%); } +.application-configuration-content { display: grid; gap: 14px; } +.application-configuration-content fieldset { margin: 0; padding: 12px; border: 1px solid #ded2c2; border-radius: 5px; } +.application-configuration-content legend { padding: 0 6px; color: #2d6652; font-weight: 800; } +.application-configuration-content p { margin: 0; color: #786d64; } +.configuration-list { display: grid; gap: 7px; margin-bottom: 10px; } +.configuration-list > div, +.configuration-list > label { min-height: 36px; display: grid; grid-template-columns: minmax(100px, .6fr) minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 7px 9px; border: 1px solid #e4d9ca; border-radius: 5px; background: #fffdf8; } +.configuration-list span { overflow: hidden; color: #786d64; text-overflow: ellipsis; white-space: nowrap; } +.configuration-list select, +.compact-configuration-row input, +.compact-configuration-row select { width: 100%; min-height: 34px; border: 1px solid #cdbda8; border-radius: 5px; background: #fffdf8; color: #302923; } +.compact-configuration-row { align-items: end; } +.compact-configuration-row label { display: grid; gap: 5px; color: #655b52; font-size: 12px; font-weight: 700; } +.compact-configuration-row button { min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; } +.icon-button { width: 32px; height: 32px; display: inline-grid; place-items: center; padding: 0; } +.application-configuration-form > footer { display: flex; justify-content: flex-end; padding: 12px 15px; border-top: 1px solid #ded2c2; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 000000000..3d471380e --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,293 @@ +export type GraphPortRole = "input" | "output" | "environment_input" | "environment_output"; + +export type GraphPort = { + id: string; + name: string; + role: GraphPortRole; + default: unknown; + defaultJulia: string; + expectedType: string; +}; + +export type SelectorDescriptor = { + type: string; + multiplicity: "one" | "optional_one" | "many"; + criteria: Record; + julia: string; +}; + +export type ModelParameter = { + value: unknown; + julia: string; + type: string; + juliaType: string; +}; + +export type ApplicationGraphNode = { + id: string; + applicationId: string; + name: string | null; + process: string; + modelType: string; + modelName: string; + module: string; + package: string | null; + modelParameters: Record; + selector: SelectorDescriptor; + targetIds: unknown[]; + targetCount: number; + targetScales: string[]; + targetKinds: string[]; + targetSpecies: string[]; + targetInstances: string[]; + timestep: unknown; + clock: unknown; + inputs: GraphPort[]; + outputs: GraphPort[]; + environmentInputs: GraphPort[]; + environmentOutputs: GraphPort[]; + inputBindings: Record; + callBindings: Record; + environment: Record | null; + meteoBindings: Record; + meteoWindow: unknown; + outputRouting: Record; + updates: Array<{ variables: string[]; after: string[] }>; + modelStorage: "shared_application" | "per_object_override"; + objectOverrides: Array>; +}; + +export type ObjectGraphNode = { + id: string; + objectId: unknown; + scale: string | null; + kind: string | null; + species: string | null; + name: string | null; + instance: string | null; + parent: unknown | null; + children: unknown[]; + hasGeometry: boolean; + hasStatus: boolean; +}; + +export type InstanceDescriptor = { + id: string; + name: string; + rootId: unknown; + kind: string | null; + species: string | null; + objectIds: unknown[]; + applicationIds: string[]; + instanceOverrides: string[]; + objectOverrides: Array>; + parametersType: string; +}; + +export type ExecutionGraphNode = { + id: string; + applicationId: string; + applicationNodeId: string; + objectId: unknown; + objectNodeId: string; + modelType: string; + modelParameters: Record; + overridden: boolean; +}; + +export type ModelGraphEdge = { + id: string; + source: string; + target: string; + sourcePort?: string | null; + targetPort?: string | null; + sourceVariable?: string | null; + targetVariable?: string | null; + sourceApplicationId?: string; + targetApplicationId?: string; + sourceObjectIds?: unknown[]; + targetObjectIds?: unknown[]; + kind: "value_binding" | "inferred_same_object" | "previous_timestep" | "manual_call" | "object_topology" | "application_target" | "update_order" | "environment_binding" | string; + origin?: string; + multiplicity?: string; + policy?: string; + selector?: SelectorDescriptor; + call?: string; + variables?: string[]; + provider?: string; + projection?: "applications" | "topology" | "resolved" | "targets"; + cycle: boolean; +}; + +export type InitializationDescriptor = { + applicationId: string; + objectId: unknown; + variable: string; + role: GraphPortRole; + disposition: "generated" | "producer_bound" | "supplied" | "environment_bound" | "unresolved"; + value: unknown; + valueJulia: string; + expectedType: string; + sourceApplicationIds: string[]; + sourceObjectIds: unknown[]; + sourceVariable: string | null; + origin: string; + previousTimeStep: boolean; +}; + +export type GraphDiagnostic = { + code: string; + severity: "error" | "warning" | "info"; + message: string; + phase: string; + applicationIds: string[]; + objectIds: unknown[]; + variable: string | null; + suggestions: string[]; +}; + +export type CycleDescriptor = { + id: string; + applicationIds: string[]; + edges: Array<{ sourceApplicationId: string; targetApplicationId: string }>; + breakCandidates: Array<{ + applicationId: string; + objectId: unknown; + input: string; + sourceApplicationIds: string[]; + sourceObjectIds: unknown[]; + sourceVariable: string; + selector: SelectorDescriptor; + }>; +}; + +export type ModelConstructorField = { + name: string; + declaredType: string; + hasDefault: boolean; + default: unknown; + defaultJulia: string | null; + defaultType: string | null; + typeParameter: string | null; + inferredChoice: string; + choices: string[]; +}; + +export type ModelDescriptor = { + type: string; + name: string; + module: string; + package: string | null; + process: string | null; + processType: string | null; + inputs: Record; + outputs: Record; + environmentInputs: Record; + environmentOutputs: Record; + timespec?: string | null; + timestepHint?: string | null; + meteoHint?: string | null; + outputPolicy?: string | null; + constructor: { + fields: ModelConstructorField[]; + parameterGroups: Record; + hasZeroArgConstructor: boolean; + constructible: boolean; + }; +}; + +export type ModelGraphView = { + schemaVersion: number; + level: "applications" | "topology" | "resolved"; + metadata: { + title: string; + modelRevision: number; + objectCount: number; + instanceCount: number; + applicationCount: number; + executionCount: number; + bindingCount: number; + callCount: number; + unresolvedInitializationCount: number; + cyclic: boolean; + strictlyCompiled: boolean; + }; + objects: ObjectGraphNode[]; + instances: InstanceDescriptor[]; + applications: ApplicationGraphNode[]; + executions: ExecutionGraphNode[]; + edges: ModelGraphEdge[]; + modelLibrary: ModelDescriptor[]; + initialization: InitializationDescriptor[]; + diagnostics: GraphDiagnostic[]; + cycles: CycleDescriptor[]; + availableActions: string[]; +}; + +export type GraphViewMode = "applications" | "topology" | "resolved"; +export type DetailMode = "overview" | "detail"; + +export type RuntimeApplicationNode = ApplicationGraphNode & { + nodeKind: "application"; + detailMode: DetailMode; + cyclic: boolean; + requiredInputPortIds: string[]; + candidatePortIds: string[]; + previousTimeStepPortIds: string[]; + cycleBreakInputPortIds: string[]; + cycleBreakMode: boolean; + onCandidateClick?: (port: GraphPort, anchor: { x: number; y: number }) => void; + onPortClick?: (port: GraphPort) => void; + onCycleBreak?: (application: ApplicationGraphNode, port: GraphPort) => void; +}; + +export type RuntimeEntityNode = { + nodeKind: "model" | "instance" | "object" | "execution" | "environment"; + title: string; + subtitle: string; + badges: string[]; + inputPortIds?: string[]; + outputPortIds?: string[]; + detail: ModelRootDescriptor | InstanceDescriptor | ObjectGraphNode | ExecutionGraphNode | EnvironmentGraphNode; +}; + +export type EnvironmentGraphNode = { + provider: string; +}; + +export type ModelRootDescriptor = { + entity: "model"; + objectCount: number; + instanceCount: number; + applicationCount: number; +}; + +export type EditorState = { + ok: boolean; + graph: ModelGraphView; + diagnostics: string[]; + canUndo: boolean; + canRedo: boolean; + url: string; + modelCode?: string; + autosavePath?: string | null; + savePath?: string | null; + recentPaths?: string[]; + selectorPreview?: SelectorPreview; + targetPreview?: TargetPreview; +}; + +export type SelectorPreview = { + applicationId: string; + input: string; + consumerObjectIds: unknown[]; + sourceObjectIds: unknown[]; + sourceApplicationIds: string[]; + bindingCount: number; + diagnostics: string[]; +}; + +export type TargetPreview = { + objectIds: unknown[]; + count: number; +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 000000000..c0ea4e10f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 000000000..da209f813 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: "dist", + emptyOutDir: true, + manifest: true, + }, +}); diff --git a/skills/plantsimengine/SKILL.md b/skills/plantsimengine/SKILL.md index ebf01e04c..3ff9c8339 100644 --- a/skills/plantsimengine/SKILL.md +++ b/skills/plantsimengine/SKILL.md @@ -1,132 +1,227 @@ --- name: plantsimengine -description: Use PlantSimEngine.jl to compose existing process models with ModelMapping, spatial multiscale MTG mappings, multirate ModelSpec configuration, and to implement or wrap new models by defining processes, inputs_, outputs_, run!, hard dependencies, and model traits. +description: Use PlantSimEngine.jl to compose models with the unified Composite Model/Object API, AppliesTo, Inputs, Calls, TimeStep, Environment, and to implement or wrap generic model kernels with inputs_, outputs_, dep, meteo traits, and run!. --- # PlantSimEngine Skill -Use this skill when helping with PlantSimEngine.jl simulations, model mappings, multiscale MTG coupling, multirate execution, or implementing/wrapping models. +Use this skill when helping with PlantSimEngine.jl model/object simulations, +multiscale or multi-plant coupling, multirate execution, microclimate binding, +or implementing and wrapping models. PlantSimEngine has two main user roles: -- **Users** compose existing models. They mostly need `ModelMapping`, `MultiScaleModel`/`ModelSpec`, status initialization, variable mappings, spatial scale symbols, and multirate policies. -- **Modelers** implement or wrap models. They need process identity, `inputs_`, `outputs_`, `run!`, hard dependencies, model traits, and tests that prove the model composes correctly. +- **Users** compose existing models. They mostly need `CompositeModel`, `Object`, + `ModelSpec`, `AppliesTo`, `Inputs`, `Calls`, `Updates`, `TimeStep`, and + `Environment`. +- **Modelers** implement or wrap generic kernels. They need process identity, + `inputs_`, `outputs_`, `dep`, `meteo_inputs_`, `meteo_outputs_`, `run!`, + model traits, and focused tests. -Prefer current APIs: `ModelMapping`, `ModelSpec`, `MultiScaleModel`, `Status`, `PreviousTimeStep`, and `run!`. Treat legacy `ModelList` as compatibility plumbing unless the user is working on legacy code. +Use the unified model/object API for multiscale, multi-plant, soil, model, and +microclimate work. Translate released mapping-era code using +`docs/src/migration_composite_model.md`. ## First Steps -1. Identify whether the request is user-side mapping work or modeler-side implementation work. +1. Identify whether the request is user-side scenario composition or + modeler-side implementation. 2. Inspect existing model declarations before inventing names: - Search for process definitions with `rg "@process|abstract type Abstract.*Model" src examples docs test`. - Search for model APIs with `rg "inputs_\\(|outputs_\\(|PlantSimEngine.run!|dep\\(" src examples test`. 3. Check model IO with `inputs(model)`, `outputs(model)`, `variables(model)`, and process identity with `process(model)` when available. -4. Validate mappings early with `dep(mapping)`, `to_initialize(mapping[, mtg])`, `resolved_model_specs(mapping)`, and `explain_model_specs(mapping)` when relevant. +4. Validate scenarios early with `explain_initialization(model)` and inspect + `explain_applications`, `explain_bindings`, `explain_calls`, + `explain_schedule`, `explain_writers`, and environment bindings. +5. Use `explain_initialization(model)` before running to distinguish supplied, + generated, producer-bound, environment-bound, and unresolved variables. ## User Workflow: Existing Models -### Single-scale mapping +### Build the object graph -Use `ModelMapping` when all models share one status. +For one object with ordinary same-object inference, use the thin constructor +that lowers directly to the same Composite Model/Object runtime: ```julia -mapping = ModelMapping( - ModelA(args...), - ModelB(args...); - status=(x=1.0, y=0.0), +model = CompositeModel( + ModelA(), + ModelB(); + status=(initial_value=1.0,), + timestep=Dates.Hour(1), ) +``` + +Use the explicit object graph below as soon as models require different target +sets or scenario policies. -out = run!(mapping, meteo) +Represent every runtime entity as an `Object` with stable identity and useful +labels. Plant topology remains scenario-defined. + +```julia +model = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, parent=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:plant_1), + Object(:soil; scale=:Soil, kind=:soil, parent=:scene); + environment=(T=25.0, Rh=0.6, Wind=1.0), +) ``` Rules: -- Inputs are matched to outputs by variable name after model declarations are flattened. -- Variables not produced by another model must be initialized through `status`, model defaults, meteo, or another supported input source. -- If two models produce the same canonical variable, inspect the graph and disambiguate before relying on incidental order. -- `Status` values are reference-backed. Writing `status.x = value` mutates the cell used by coupled models. +- `scale`, `kind`, `species`, and `name` are selector labels, not a fixed plant + ontology. +- Use any hierarchy required by the simulated object. +- `Status` is reference-backed. Same-rate coupling should preserve those + references instead of copying values. +- Use `CompositeModelTemplate` and `ObjectInstance` for repeated plants with shared + model objects and parameters. +- Adapt an existing MTG with `CompositeModel(mtg; applications=..., environment=...)`, + or inspect `objects_from_mtg(mtg; ...)` before constructing the model. + Accessors can translate MTG attributes into ids, labels, status, and + geometry. -### Spatial multiscale mapping +### Apply models -Use `ModelMapping` keyed by scale symbols when running on an MTG. Prefer symbol scales such as `:Scene`, `:Plant`, `:Leaf`, `:Internode`; string scale names are deprecated. +Wrap each scenario application in `ModelSpec` and select its target objects +with `AppliesTo`. ```julia -mapping = ModelMapping( - :Scene => ( - SceneModel(), - ), - :Plant => ( - MultiScaleModel( - PlantModel(), - [:TT_cu => (:Scene => :TT_cu)], - ), - ), - :Leaf => ( - LeafModel(), - Status(carbon_biomass=1.0), - ), +applications = ( + ModelSpec(LeafModel(); name=:leaf_model) |> + AppliesTo(Many(scale=:Leaf)), + + ModelSpec(SoilModel(); name=:soil_model) |> + AppliesTo(One(scale=:Soil)), ) -out = run!(mtg, mapping, meteo) +model = CompositeModel(model_objects...; applications=applications, environment=backend) ``` -Each scale tuple can contain models, `ModelSpec`s, `MultiScaleModel`s, and optional `Status(...)` initializers for variables local to that scale. A model only sees variables in its local status unless they are mapped from another scale or supplied by runtime input binding. +Use explicit application names when a process is applied more than once to the +same object set. Singular producer references use `application=`. A +`Many(process=...)` filter is reserved for explicit discovery across several +applications, such as mounted template instances. -### Variable mapping forms +### Couple values with Inputs -Use `MultiScaleModel(model, mapped_variables)` or pipe through `ModelSpec(model) |> MultiScaleModel(mapped_variables)`. - -Common forms: +Declare each consumer's value source with `Inputs`. ```julia -:x => :Plant # scalar read from :Plant, same variable name -:x => (:Plant => :y) # scalar read from :Plant variable :y -:x => [:Leaf] # vector read from all :Leaf nodes -:x => [:Leaf, :Internode] # vector read from several scales -:x => [:Leaf => :a, :Internode => :b] # vector read with per-scale renaming -:x => (Symbol("") => :y) # same-scale rename or alias -PreviousTimeStep(:x) # break current-step dependency inference -PreviousTimeStep(:x) => (:Plant => :y) +ModelSpec(AllocationModel(); name=:allocation) |> + AppliesTo(Many(scale=:Plant)) |> + Inputs( + :leaf_carbon => Many( + scale=:Leaf, + within=Subtree(), + application=:leaf_carbon, + var=:leaf_carbon, + ), + ) ``` Semantics: -- Scalar cross-scale mappings share a `Ref`; the source scale is expected to be unique at runtime. -- Vector mappings create a `RefVector`; models must handle vector inputs, and order follows MTG traversal order. -- Same-scale renaming creates a per-status alias, not a graph-wide shared variable. -- `PreviousTimeStep` prevents same-step dependency edges and is the standard way to break cycles. +- `One(...)`, `OptionalOne(...)`, and `Many(...)` make multiplicity explicit. +- `Self()` is only the consumer object. `Subtree()` is that object plus its + descendants. `SelfPlant()` is the nearest containing plant and its subtree. + `SceneScope()` selects across the model. +- Same-rate scalar and many-object inputs use shared `Ref`s or reference + vectors where possible. +- Cross-rate values use typed temporal streams. +- Rename variables with + `Inputs(:local_name => One(within=Self(), var=:source_name))`. +- Use `PreviousTimeStep(:x) => selector` for an explicit lag and cycle break. +- An unresolved `OptionalOne` input keeps its `inputs_` default. -### Multirate configuration +### Control manual model calls -Use `ModelSpec` when models run at different clocks, consume streams with temporal policies, aggregate meteo, or need scoped streams. +Use `Calls` when the parent model must own the call stack, such as an iterative +energy balance. ```julia -daily = ClockSpec(24.0, 1.0) - -plant_spec = - ModelSpec(PlantDailyModel()) |> - MultiScaleModel([:leaf_assim => [:Leaf => :A]]) |> - TimeStepModel(daily) |> - InputBindings(; - leaf_assim=(process=:leafassimilation, scale=:Leaf, var=:A, policy=Integrate()), - ) |> - ScopeModel(:plant) +ModelSpec(SceneEnergyBalance(); name=:scene_energy) |> + AppliesTo(One(scale=:Scene)) |> + Calls( + :leaf_energy => Many( + scale=:Leaf, + within=SceneScope(), + process=:energy_balance, + ), + ) ``` -Policies: +Inside `run!`, execute all targets with `run_call!(extra, :name)`, which always +returns a vector-like `CallTargets` collection. For selective or iterative +control, retrieve the collection with `call_targets(extra, :name)` and execute +individual targets. `run_call!` defaults to `publish=false` for trial +iterations. Use `publish=true` once for the accepted state. + +### Configure rates and environment + +Use `Dates.Period` values directly: -- `HoldLast()` uses the latest producer value. -- `Interpolate()` interpolates or holds/extrapolates producer streams. -- `Integrate()` reduces over the consumer window, usually for fluxes or accumulations. -- `Aggregate()` reduces over the consumer window, usually for means, extrema, or summaries. +```julia +ModelSpec(DailyPlantModel(); name=:daily_plant) |> + AppliesTo(Many(scale=:Plant)) |> + Inputs( + :leaf_fluxes => Many( + scale=:Leaf, + within=Subtree(), + var=:flux, + policy=Integrate(), + window=Dates.Day(1), + ), + ) |> + TimeStep(Dates.Day(1)) +``` -Precedence: +Policies are `HoldLast`, `Interpolate`, `Integrate`, and `Aggregate`. When an +`Inputs(...)` selector omits `policy=...`, a unique producer's +`output_policy(::Type{<:Model})` trait supplies the default policy; explicit +selector policies override the trait. +Environment variables come from `meteo_inputs_` and `meteo_outputs_`. +`meteo_hint(...).bindings` can provide model-author default source remaps, and +`Environment(; sources=...)` is the scenario-level override. Use +`Environment(provider=:grid)` only when overriding automatic binding. +For global `Weather` tables, model applications sample meteorology at their +compiled clock using the `meteo_hint` reducer/window. An +`Environment(; sources=...)` override changes the source but preserves that +reducer, and all objects in one application reuse the same sampled row for a +given timestep. Spatial backends define their own temporal sampling semantics. +When no `TimeStep(...)` is provided, the model scheduler honors +`timespec(::Type{<:Model})`; an explicit `TimeStep(...)` remains the +scenario-level override. If the clock falls back to the model base step, +`timestep_hint(::Type{<:Model})` required bounds are validated as compatibility +constraints. + +### Handle lifecycle changes + +Use `register_object!`, `remove_object!`, `reparent_object!`, and +`move_object!`. Structural changes refresh selectors, carriers, calls, +writers, and schedules before the next timestep. Geometry-only changes refresh +the affected environment bindings. + +### Validate the compiled scenario -1. Input policy: explicit `InputBindings(..., policy=...)` > producer `output_policy` > `HoldLast()`. -2. Timestep: `TimeStepModel(...)` > non-default `timespec(model)` > meteo base step. -3. Meteo sampling: explicit `MeteoBindings(...)`/`MeteoWindow(...)` > `meteo_hint(...)` > runtime defaults. +```julia +explain_applications(model) +explain_bindings(model) +explain_calls(model) +explain_schedule(model) +explain_writers(model) +explain_model_bundles(model) +``` -Use explicit `InputBindings` when several models/scales can produce the same variable, names differ, or the default temporal policy is not correct. Use `OutputRouting(; x=:stream_only)` when a producer should publish a stream without becoming the canonical status owner for `x`. +Run with `simulation = run!(model; steps=n, outputs=:none)`. Use +`outputs=:all` or `outputs=OutputRequest(...)` when the user needs retained or +resampled outputs. Requests are materialized from retained typed streams after +the run, and dynamic objects are exported only across their own sample +interval. Continue the same time/environment/multirate state with +`continue!(simulation; steps=n)` or `step!(simulation)`. Use +`explain_output_retention(model; outputs=...)` before a long run and +`explain_output_retention(simulation)` afterward. ## Modeler Workflow: New Or Wrapped Models @@ -164,7 +259,9 @@ Rules: - Use `NamedTuple()` for no inputs or no outputs. - Read and write model state through `status`. Do not store timestep-varying state in the model object. - Read weather through `meteo` and physical constants through `constants`. -- In MTG runs, `extra` is the `GraphSimulation`; do not use user-defined `extra` arguments for MTG APIs. +- In model runs, `extra` is a `RunContext`. Use its public hard-call and + lifecycle APIs rather than attaching unrelated user data. Obtain the live + model with `runtime_model(extra)`; do not inspect `extra.compiled.model`. - If a variable appears in both `inputs_` and `outputs_` with the same name, remember that `variables(model)` merges declarations and later output declarations win. ### Wrapping existing code @@ -180,40 +277,37 @@ When wrapping an external or existing model: ### Hard dependencies -Use hard dependencies when a parent model directly calls a required submodel inside its own `run!`. The runtime records the dependency but does not automatically execute it for the parent. +Use a `Call(...)` dependency default when a parent model directly calls a +required submodel inside its own `run!`. Scenario-level `Calls(...)` can +override the default selector without changing the kernel. ```julia PlantSimEngine.dep(::ParentModel) = ( - child_process=AbstractChild_ProcessModel, + child=Call(process=:child_process), ) function PlantSimEngine.run!(m::ParentModel, models, status, meteo, constants, extra=nothing) - run!(models.child_process, models, status, meteo, constants, extra) + child = only(run_call!(extra, :child; meteo=meteo, publish=true)) status.parent_output = g(status.child_output) end ``` -For multiscale hard dependencies, declare the target scale: +The scenario decides the concrete target objects: ```julia -PlantSimEngine.dep(::ParentModel) = ( - child_process=AbstractChild_ProcessModel => (:Leaf,), -) +ModelSpec(ParentModel()) |> + AppliesTo(One(scale=:Scene)) |> + Calls(:child => Many(scale=:Leaf, process=:child_process)) ``` -Then call the child model explicitly on the correct target status, usually via `extra.statuses[:Leaf]` and `extra.models[:Leaf]`. Be careful: hard-dependency IO still participates in graph compilation through the owning soft node. +Hard calls are never automatically executed for the parent. Trial +`run_call!` calls do not publish; pass `publish=true` for the accepted state. ### Model traits Add traits only when they are true for the model implementation, not merely convenient for one scenario. ```julia -PlantSimEngine.TimeStepDependencyTrait(::Type{<:MyModel}) = - PlantSimEngine.IsTimeStepIndependent() - -PlantSimEngine.ObjectDependencyTrait(::Type{<:MyModel}) = - PlantSimEngine.IsObjectIndependent() - PlantSimEngine.timespec(::Type{<:MyDailyModel}) = ClockSpec(24.0, 1.0) PlantSimEngine.output_policy(::Type{<:MyFluxModel}) = ( @@ -229,32 +323,58 @@ PlantSimEngine.meteo_hint(::Type{<:MyModel}) = ( ) ``` -Parallel traits are mainly for single-scale execution. Multirate MTG runs are currently sequential. +There is currently no public parallel executor API. Do not promise parallel or +distributed execution; establish correctness and independence before any +future parallel implementation. -## Validation Checklist +### Source ownership + +- `src/composite_model_api.jl` is only the dependency-ordered include boundary. +- `src/composite_model/registry_topology.jl` owns objects, instances, and lifecycle. +- `src/composite_model/selectors.jl` owns selector resolution. +- `src/composite_model/compilation.jl` owns bindings, calls, writers, and schedules. +- `src/composite_model/environment_bindings.jl` owns environment coupling. +- `src/composite_model/runtime_outputs.jl` owns execution and output streams. -For user mappings: +## Validation Checklist -- `to_initialize(mapping)` or `to_initialize(mapping, mtg)` lists only variables the user should really provide. -- `dep(mapping)` succeeds and the dependency graph matches the expected coupling. -- `explain_model_specs(mapping)` is sensible for multirate runs. -- Cycles are either absent or intentionally broken with `PreviousTimeStep`. -- Ambiguous multirate producers are resolved with `InputBindings`. +For user scenarios: + +- `explain_initialization(model)` contains no unresolved required values. +- `explain_applications` shows the expected application/object pairs. +- `explain_bindings` shows the intended source ids, source applications, + temporal policies, and carrier semantics. +- `explain_calls`, `explain_schedule`, and `explain_writers` match the intended + manual call stack and execution order. +- `explain_execution_plan` groups large homogeneous object sets into concrete + batches; unexpected one-object batches usually indicate heterogeneous model, + status, binding, or environment types. +- Cycles are absent or intentionally broken with `PreviousTimeStep`. +- Ambiguous singular producers are resolved with `application=`. +- Environment explanations show the expected provider, cell, geometry source, + source variables, and whether a temporal sampler is compiled. For model implementations: - Unit-test `inputs_`, `outputs_`, and a direct `run!` call with a minimal `Status`. -- Test single-scale composition when the model is meant to couple by variable name. -- Test MTG/multiscale mapping when the model expects scalar refs, `RefVector` inputs, or cross-scale writes. -- Test multirate behavior when traits, `InputBindings`, `MeteoBindings`, or `OutputRouting` matter. +- Test model composition when the model is meant to couple by variable name. +- Test `Inputs(...)` when the model expects scalar refs, `RefVector` inputs, or + renamed variables. +- Test multirate behavior when `TimeStep`, temporal policies, windows, or + output routing matter. - Check hard dependencies by proving the parent actually calls the child and uses the child's outputs. ## Common Pitfalls - Do not confuse hard dependencies with soft dependency scheduling. Hard dependencies are manual calls. -- Do not rely on MTG topology for model execution order. Soft dependency order controls model order. -- Do not assume `RefVector` order has biological meaning. -- Do not map scalar reads from a scale that can have several runtime nodes unless the model really expects the chosen unique source behavior. +- Do not rely on object topology or declaration order for model execution + order. Compiled input and update edges control scheduling. +- Do not attach biological meaning to incidental collection order. Selectors + use stable object-id order. +- Do not use `One(...)` when several objects can match; use `Many(...)` or + disambiguate explicitly. - Do not use strings for new scale declarations. Use symbols. -- Do not mutate MTG topology after status initialization unless you reinitialize or use supported dynamic helpers. +- Do not mutate object topology outside `register_object!`, `remove_object!`, + or `reparent_object!`; bypassing lifecycle hooks leaves caches stale. +- Do not publish every iterative hard call. Publish only the accepted state. - Do not use `PreviousTimeStep` as a numerical lag unless the initial value and expected temporal semantics are explicit. diff --git a/src/Abstract_model_structs.jl b/src/Abstract_model_structs.jl index 9cef7b087..7af82df2c 100644 --- a/src/Abstract_model_structs.jl +++ b/src/Abstract_model_structs.jl @@ -17,12 +17,11 @@ process(x::Pair{Symbol,A}) where {A<:AbstractModel} = first(x) process_(x) = error("process() is not defined for $(x)") """ - model_(m::AbstractModel) + dep(model) -Get the model of an AbstractModel (it is the model itself if it is not a MultiScaleModel). +Return model-level default `Input(...)` and `Call(...)` declarations. Models +without explicit coupling requirements return an empty `NamedTuple`. """ +dep(::AbstractModel) = NamedTuple() + model_(m::AbstractModel) = m -get_models(m::AbstractModel) = [model_(m)] # Get the models of an AbstractModel -# Note: it is returning a vector of models, because in this case the user provided a single model instead of a vector of. -get_status(m::AbstractModel) = nothing -get_mapped_variables(m::AbstractModel) = Pair{Symbol,String}[] \ No newline at end of file diff --git a/src/ModelSpec.jl b/src/ModelSpec.jl new file mode 100644 index 000000000..85636e9b8 --- /dev/null +++ b/src/ModelSpec.jl @@ -0,0 +1,423 @@ +""" + ModelSpec(model; name=nothing, applies_to=nothing, inputs=NamedTuple(), + calls=NamedTuple(), environment=nothing, timestep=nothing, + meteo_bindings=NamedTuple(), meteo_window=nothing, + output_routing=NamedTuple(), updates=()) + +Configuration for one model application in a `CompositeModel`. + +`ModelSpec` keeps model implementation and scenario-specific usage metadata in one place. +This allows modelers to publish reusable models while users decide how models are coupled in +their simulation setup. +""" +struct ModelSpec{M,N,AT,IN,IO,CA,CO,EV,TS,MB,MW,OR,UP} + model::M + name::N + applies_to::AT + inputs::IN + input_origins::IO + calls::CA + call_origins::CO + environment::EV + timestep::TS + meteo_bindings::MB + meteo_window::MW + output_routing::OR + updates::UP +end + +""" + Updates(vars...; after=nothing) + +Scenario-level declaration that a model updates variables which may also be +computed by another model at the same scale. + +`after` contains canonical application identifiers. It is intentionally +scenario-level metadata: the model implementation stays reusable, while the +simulation setup can declare ordering constraints that only exist in this +coupling. +""" +struct Updates{V,A} + variables::V + after::A +end + +function Updates(vars::Vararg{Union{Symbol,AbstractString}}; after=nothing) + isempty(vars) && error("Updates(...) requires at least one variable.") + return Updates(Tuple(Symbol(v) for v in vars), _normalize_update_after(after)) +end + +function _normalize_update_after(after) + isnothing(after) && return () + after isa Union{Symbol,AbstractString} && return (Symbol(after),) + return Tuple(Symbol(v) for v in after) +end + +_normalize_updates(updates::Updates) = (updates,) + +function _normalize_updates(updates::Tuple) + all(update -> update isa Updates, updates) || error( + "Unsupported updates tuple. Use `Updates(:var; after=:application)` entries." + ) + return updates +end + +function _normalize_updates(updates::AbstractVector) + all(update -> update isa Updates, updates) || error( + "Unsupported updates vector. Use `Updates(:var; after=:application)` entries." + ) + return Tuple(updates) +end + +function _normalize_updates(updates) + updates == NamedTuple() && return () + updates == () && return () + error( + "Unsupported updates metadata `$(updates)` of type `$(typeof(updates))`. ", + "Use `Updates(:var; after=:application)` or a tuple/vector of `Updates`." + ) +end + +function ModelSpec( + model::AbstractModel; + name=nothing, + applies_to=nothing, + inputs=NamedTuple(), + input_origins=nothing, + calls=NamedTuple(), + call_origins=nothing, + environment=nothing, + timestep=nothing, + meteo_bindings=NamedTuple(), + meteo_window=nothing, + output_routing=NamedTuple(), + updates=() +) + base_model = model + + normalized_name = _normalize_application_name(name) + default_inputs = _model_default_value_inputs(base_model) + explicit_inputs = _normalize_application_bindings(inputs) + normalized_inputs = _merge_value_inputs(default_inputs, explicit_inputs) + normalized_input_origins = isnothing(input_origins) ? + _binding_origins(default_inputs, explicit_inputs) : + _normalize_binding_origins(input_origins, normalized_inputs) + default_calls = _model_default_model_calls(base_model) + explicit_calls = _normalize_application_bindings(calls) + normalized_calls = _merge_value_inputs(default_calls, explicit_calls) + normalized_call_origins = isnothing(call_origins) ? + _binding_origins(default_calls, explicit_calls) : + _normalize_binding_origins(call_origins, normalized_calls) + normalized_meteo_bindings = _normalize_meteo_bindings(meteo_bindings) + normalized_meteo_window = _normalize_meteo_window(meteo_window) + normalized_output_routing = _normalize_output_routing(output_routing) + normalized_updates = _normalize_updates(updates) + return ModelSpec{typeof(base_model),typeof(normalized_name),typeof(applies_to),typeof(normalized_inputs),typeof(normalized_input_origins),typeof(normalized_calls),typeof(normalized_call_origins),typeof(environment),typeof(timestep),typeof(normalized_meteo_bindings),typeof(normalized_meteo_window),typeof(normalized_output_routing),typeof(normalized_updates)}( + base_model, + normalized_name, + applies_to, + normalized_inputs, + normalized_input_origins, + normalized_calls, + normalized_call_origins, + environment, + timestep, + normalized_meteo_bindings, + normalized_meteo_window, + normalized_output_routing, + normalized_updates + ) +end + +function ModelSpec( + spec::ModelSpec; + model=spec.model, + name=spec.name, + applies_to=spec.applies_to, + inputs=spec.inputs, + input_origins=spec.input_origins, + calls=spec.calls, + call_origins=spec.call_origins, + environment=spec.environment, + timestep=spec.timestep, + meteo_bindings=spec.meteo_bindings, + meteo_window=spec.meteo_window, + output_routing=spec.output_routing, + updates=spec.updates +) + ModelSpec(model; name=name, applies_to=applies_to, inputs=inputs, input_origins=input_origins, calls=calls, call_origins=call_origins, environment=environment, timestep=timestep, meteo_bindings=meteo_bindings, meteo_window=meteo_window, output_routing=output_routing, updates=updates) +end + +as_model_spec(spec::ModelSpec) = spec +as_model_spec(model::AbstractModel) = ModelSpec(model) + +function _with_spec(model_or_spec; kwargs...) + return ModelSpec(as_model_spec(model_or_spec); kwargs...) +end + +function _with_spec_bindings(model_or_spec, bindings, defaults) + spec = as_model_spec(model_or_spec) + explicit = _normalize_application_bindings(bindings) + origins = _binding_origins(defaults(model_(spec)), explicit) + return spec, explicit, origins +end + +""" + with_name(model_or_spec, name) + +Return a `ModelSpec` with an explicit model-application name. +""" +function with_name(model_or_spec, name) + return _with_spec(model_or_spec; name=_normalize_application_name(name)) +end + +""" + with_applies_to(model_or_spec, selector) + +Return a `ModelSpec` with an explicit model-application target selector. +""" +function with_applies_to(model_or_spec, selector) + return _with_spec(model_or_spec; applies_to=selector) +end + +""" + with_inputs(model_or_spec, bindings) + +Return a `ModelSpec` with unified composite-model/object value-input bindings. +""" +function with_inputs(model_or_spec, bindings) + spec, explicit, origins = + _with_spec_bindings(model_or_spec, bindings, _model_default_value_inputs) + return ModelSpec(spec; inputs=explicit, input_origins=origins) +end + +""" + with_calls(model_or_spec, bindings) + +Return a `ModelSpec` with unified composite-model/object manual model-call bindings. +""" +function with_calls(model_or_spec, bindings) + spec, explicit, origins = + _with_spec_bindings(model_or_spec, bindings, _model_default_model_calls) + return ModelSpec(spec; calls=explicit, call_origins=origins) +end + +""" + with_environment(model_or_spec, environment) + +Return a `ModelSpec` with composite-model/object environment configuration metadata. +""" +function with_environment(model_or_spec, environment) + return _with_spec(model_or_spec; environment=environment) +end + +""" + with_timestep(model_or_spec, timestep) + +Return a `ModelSpec` with an explicit user-selected timestep. +""" +function with_timestep(model_or_spec, timestep) + return _with_spec(model_or_spec; timestep=timestep) +end + +""" + with_meteo_bindings(model_or_spec, bindings) + +Return a `ModelSpec` with explicit meteo aggregation bindings. +""" +with_meteo_bindings(model_or_spec, bindings) = + _with_spec(model_or_spec; meteo_bindings=_normalize_meteo_bindings(bindings)) + +""" + with_meteo_window(model_or_spec, window) + +Return a `ModelSpec` with explicit weather-window selection strategy. +""" +with_meteo_window(model_or_spec, window) = + _with_spec(model_or_spec; meteo_window=_normalize_meteo_window(window)) + +""" + with_output_routing(model_or_spec, routing) + +Return a `ModelSpec` with explicit user-defined output routing. +""" +with_output_routing(model_or_spec, routing) = + _with_spec(model_or_spec; output_routing=_normalize_output_routing(routing)) + +""" + with_updates(model_or_spec, updates) + +Return a `ModelSpec` with explicit variable-update metadata. +""" +function with_updates(model_or_spec, updates) + spec = as_model_spec(model_or_spec) + return ModelSpec(spec; updates=(spec.updates..., _normalize_updates(updates)...)) +end + +(updates::Updates)(model_or_spec) = with_updates(model_or_spec, updates) + +function _normalize_meteo_binding(binding) + if binding isa DataType + binding <: PlantMeteo.AbstractTimeReducer || error( + "Unsupported environment reducer type `$(binding)`. ", + "Use a PlantMeteo reducer type/instance, callable, or NamedTuple(source=..., reducer=...)." + ) + return binding + elseif binding isa PlantMeteo.AbstractTimeReducer + return binding + elseif binding isa Function + return binding + elseif binding isa NamedTuple + return binding + end + error( + "Unsupported environment binding `$(binding)` of type `$(typeof(binding))`. ", + "Use a PlantMeteo reducer type/instance, callable, or NamedTuple(source=..., reducer=...)." + ) +end + +function _normalize_meteo_bindings(bindings::NamedTuple) + normalized = Pair{Symbol,Any}[] + for (k, v) in pairs(bindings) + push!(normalized, k => _normalize_meteo_binding(v)) + end + return (; normalized...) +end + +function _normalize_meteo_bindings(bindings) + error("Unsupported environment bindings `$(bindings)` of type `$(typeof(bindings))`.") +end + +function _normalize_meteo_window(window) + if isnothing(window) + return nothing + elseif window isa DataType + window <: PlantMeteo.AbstractSamplingWindow || error( + "Unsupported environment sampling-window type `$(window)`. ", + "Use a PlantMeteo sampling-window type/instance." + ) + return window() + elseif window isa PlantMeteo.AbstractSamplingWindow + return window + end + + error( + "Unsupported environment sampling window `$(window)` of type `$(typeof(window))`. ", + "Use a PlantMeteo sampling-window type/instance." + ) +end + +function _normalize_output_routing(routing::NamedTuple) + normalized = Pair{Symbol,Symbol}[] + for (k, v) in pairs(routing) + mode = Symbol(v) + mode in (:canonical, :stream_only) || error( + "Unsupported output routing mode `$(mode)` for output `$(k)`. ", + "Allowed values are `:canonical` and `:stream_only`." + ) + push!(normalized, k => mode) + end + return (; normalized...) +end + +function _normalize_output_routing(routing) + error("Unsupported OutputRouting value `$(routing)` of type `$(typeof(routing))`. Use a NamedTuple, e.g. `OutputRouting(; x=:stream_only)`.") +end + +""" + AppliesTo(selector) + +Pipe-style transform that sets the object selector where a model application +runs in the unified composite-model/object API. +""" +AppliesTo(selector) = x -> with_applies_to(x, selector) + +""" + Inputs(bindings...) + Inputs(; kwargs...) + +Pipe-style transform that sets value-input bindings in the unified +composite-model/object API. +""" +Inputs(bindings::Pair...) = x -> with_inputs(x, bindings) +Inputs(bindings::NamedTuple) = x -> with_inputs(x, bindings) +Inputs(; kwargs...) = Inputs((; kwargs...)) + +""" + Calls(bindings...) + Calls(; kwargs...) + +Pipe-style transform that sets manual model-call bindings in the unified +composite-model/object API. +""" +Calls(bindings::Pair...) = x -> with_calls(x, bindings) +Calls(bindings::NamedTuple) = x -> with_calls(x, bindings) +Calls(; kwargs...) = Calls((; kwargs...)) + +""" + TimeStep(timestep) + +Pipe-style transform that sets a user-selected timestep for a model +application. +""" +TimeStep(timestep) = x -> with_timestep(x, timestep) + +""" + Environment(config) + Environment(; kwargs...) + +Pipe-style transform that stores composite-model/object environment configuration +metadata on a `ModelSpec`. +""" +Environment(config) = x -> with_environment(x, config isa EnvironmentConfig ? config : EnvironmentConfig(config)) +Environment(; kwargs...) = Environment((; kwargs...)) + +""" + OutputRouting(routing) + OutputRouting(; kwargs...) + +Pipe-style transform that sets output publication mode for a model. + +This is mainly used to disambiguate publishers in multi-rate runs when several +models write variables with the same name. + +# Arguments +- `routing::NamedTuple`: maps output variable symbols to routing mode. +- `kwargs...`: keyword shorthand equivalent to a `NamedTuple`. + +Allowed routing values: +- `:canonical` (default): output is considered canonical at that scale and can + be auto-selected as source/export publisher. +- `:stream_only`: output is kept only in temporal streams and excluded from + canonical publisher resolution. + +# Example +```julia +ModelSpec(AltSourceModel()) |> +OutputRouting(; C=:stream_only) +``` +""" +OutputRouting(routing) = x -> with_output_routing(x, routing) +OutputRouting(; kwargs...) = OutputRouting((; kwargs...)) + +model_(m::ModelSpec) = m.model +process(m::ModelSpec) = process(model_(m)) +timestep(m::ModelSpec) = m.timestep +inputs_(m::ModelSpec) = inputs_(model_(m)) +outputs_(m::ModelSpec) = outputs_(model_(m)) + +function dep(spec::ModelSpec) + dependencies = dep(model_(spec)) + kept = Pair{Symbol,Any}[] + for (name, selector) in pairs(dependencies) + selector isa Union{Input,Call} && continue + push!(kept, name => selector) + end + return (; kept...) +end +meteo_inputs_(m::ModelSpec) = meteo_inputs_(model_(m)) +meteo_outputs_(m::ModelSpec) = meteo_outputs_(model_(m)) + +function run!(m::ModelSpec, models, status, meteo, constants=nothing, extra=nothing) + return run!(model_(m), models, status, meteo, constants, extra) +end diff --git a/src/PlantSimEngine.jl b/src/PlantSimEngine.jl index 1151b8f7a..b80884dee 100644 --- a/src/PlantSimEngine.jl +++ b/src/PlantSimEngine.jl @@ -1,147 +1,180 @@ module PlantSimEngine -# FOr data formatting: +# Data formatting: import DataFrames import Tables -import DataAPI import Dates -import CSV # For reading csv files with variables() +# Reading CSV files in `variables`. +import CSV -# For graph dependency: -import AbstractTrees import Term import Markdown - -# For multi-threading: -import FLoops: @floop, @init, ThreadedEx, SequentialEx, DistributedEx +import JSON +import InteractiveUtils +import Base: position # For MTG compatibility: import MultiScaleTreeGraph import MultiScaleTreeGraph: symbol, node_id -# To compute mean: +# Statistics helpers: import Statistics -# For avoiding name conflicts when generating models from status vectors -import SHA: sha1 - +# Keep PlantMeteo names available for re-export without local wrapper types. using PlantMeteo -# UninitializedVar + PreviousTimeStep: +# Temporal input marker: include("variables_wrappers.jl") -# Docs templates: -include("doc_templates/mtg-related.jl") - # Models: include("Abstract_model_structs.jl") # Multi-rate scaffolding: include("time/multirate.jl") -# Simulation row (status): +# Object status and reference carriers: include("component_models/Status.jl") include("component_models/RefVector.jl") -# Simulation table (time-step table, from PlantMeteo): +# CompositeModel/object compiler and runtime: +include("composite_model_api.jl") + +# Time-step table adapter: include("component_models/TimeStepTable.jl") -# Declaring the dependency graph -include("dependencies/dependency_graph.jl") - -# List of models: -include("component_models/ModelList.jl") # deprecated, to be removed in favor of ModelMapping -include("mtg/MultiScaleModel.jl") -include("mtg/ModelSpec.jl") -include("mtg/mapping/mapping.jl") - -# Getters / setters for status: -include("component_models/get_status.jl") - -# Transform into a dataframe: -include("dataframe.jl") - -# Computing model dependencies: -include("dependencies/soft_dependencies.jl") -include("dependencies/hard_dependencies.jl") -include("dependencies/traversal.jl") -include("dependencies/is_graph_cyclic.jl") -include("dependencies/printing.jl") -include("dependencies/dependencies.jl") -include("dependencies/get_model_in_dependency_graph.jl") - -# MTG compatibility: -include("mtg/GraphSimulation.jl") -include("mtg/mapping/getters.jl") -include("mtg/mapping/compute_mapping.jl") -include("mtg/mapping/reverse_mapping.jl") -include("mtg/model_spec_inference.jl") -include("mtg/model_spec_validation.jl") -include("mtg/initialisation.jl") -include("mtg/save_results.jl") -include("mtg/add_organ.jl") +# Model application configuration: +include("ModelSpec.jl") # Model evaluation (statistics): include("evaluation/statistics.jl") # Traits include("traits/table_traits.jl") -include("traits/parallel_traits.jl") # Processes: -include("processes/model_initialisation.jl") include("processes/models_inputs_outputs.jl") include("processes/process_generation.jl") -include("checks/dimensions.jl") -# Multi-rate runtime: +# Model discovery for static and interactive graph tooling: +include("model_discovery.jl") + +# CompositeModel timing and environment runtime: include("time/runtime/clocks.jl") -include("time/runtime/scopes.jl") -include("time/runtime/bindings.jl") -include("time/runtime/input_resolution.jl") -include("time/runtime/publishers.jl") include("time/runtime/output_export.jl") include("time/runtime/meteo_sampling.jl") +include("time/runtime/environment_backends.jl") -# Simulation: -include("run.jl") +# Static CompositeModel graph compilation and visualization: +include("visualization/model_graph_view.jl") +include("visualization/model_graph_editor_api.jl") # Fitting include("evaluation/fit.jl") -# Utilities for mapping initialisation -include("mtg/mapping/model_generation_from_status_vectors.jl") - # Examples include("examples_import.jl") +""" + PlantSimEngine.Advanced + +Qualified compiler, cache, and low-level runtime extension APIs. These symbols +are available for diagnostics, package integration, and compiler development, +but are intentionally not part of the default user namespace. +""" +module Advanced +import ..PlantSimEngine: + ObjectRegistry, + CompiledCompositeModel, + CompiledModelApplication, + CompiledModelInputBinding, + CompiledModelCallBinding, + CompiledEnvironmentBinding, + CompiledEnvironmentBindings, + ObjectRefVector, + TimeStepTable, + compile_composite_model, + refresh_bindings!, + refresh_environment_bindings!, + compile_environment_bindings, + bind_environment, + bindings_dirty, + environment_bindings_dirty, + model_revision, + environment_revision, + compiled_bindings, + compiled_environment_bindings + +export ObjectRegistry +export CompiledCompositeModel, CompiledModelApplication +export CompiledModelInputBinding, CompiledModelCallBinding +export CompiledEnvironmentBinding, CompiledEnvironmentBindings +export ObjectRefVector, TimeStepTable +export compile_composite_model, refresh_bindings!, refresh_environment_bindings! +export compile_environment_bindings, bind_environment +export bindings_dirty, environment_bindings_dirty +export model_revision, environment_revision +export compiled_bindings, compiled_environment_bindings +end + export PreviousTimeStep export AbstractModel -export ScopeId, ClockSpec, ModelKey, OutputKey +export ClockSpec export SchedulePolicy, HoldLast, Interpolate, Integrate, Aggregate export AbstractTimeReducer, MeanWeighted, MeanReducer, SumReducer, MinReducer, MaxReducer, FirstReducer, LastReducer, RadiationEnergy -export OutputCache, HoldLastCache, InterpolateCache, IntegrateCache, AggregateCache -export TemporalState export OutputRequest, collect_outputs -export effective_rate_summary -export ModelList, MultiScaleModel, ModelMapping, ModelSpec, TimeStepModel, InputBindings, MeteoBindings, MeteoWindow, OutputRouting, ScopeModel -export resolved_model_specs, explain_model_specs +export CompositeModel, Object, ObjectId, CompositeModelTemplate, ObjectInstance, Override +export add_organ!, register_object!, remove_object!, reparent_object!, move_object!, update_geometry! +export mark_environment_binding_dirty! +export objects_from_mtg, object_ids, model_objects, resolve_object_ids, resolve_objects, explain_objects, explain_instances, explain_scopes +export geometry, position, bounds +export explain_applications, explain_bindings, explain_calls, explain_model_bundles, explain_writers +export input_carrier, input_value, has_reference_carrier +export RunContext, CallTarget, CallTargets, Simulation, runtime_model, current_step, outputs, explain_outputs +export explain_initialization +export explain_execution_plan, explain_output_retention +export explain_environment_bindings +export SceneScope, Self, Subtree, SelfPlant, Ancestor, Scope, Kind, Species, Scale, Relation +export One, OptionalOne, Many, ObjectAddress, object_address +export Input, Call, AppliesTo, Inputs, Calls, TimeStep, Environment +export application_name, applies_to, value_inputs, model_calls, environment_config +export ModelSpec, Updates, OutputRouting +export call_targets, run_call!, explain_schedule export RMSE, NRMSE, EF, dr -export Status, TimeStepTable, status -export init_status! -export add_organ! +export Status export @process, process -export to_initialize, is_initialized, init_variables, dep -export inputs, outputs, variables, convert_outputs +export init_variables, dep +export inputs, outputs, variables export timespec, output_policy, timestep_hint, meteo_hint -export input_bindings, meteo_bindings, meteo_window, output_routing, model_scope -export run! +export meteo_bindings, meteo_window, output_routing, updates +export meteo_inputs, meteo_inputs_, meteo_outputs, meteo_outputs_ +export validate_meteo_inputs +export available_processes, available_models, model_descriptor, model_constructor_descriptor +export ModelGraphDiagnostic, CompositeModelCompilationReport, ModelGraphView +export compile_model_report, compile_model_graph, model_graph_view +export model_graph_view_json, model_graph_view_html, write_model_graph_view +export AbstractModelGraphEdit, AddModelApplication, RemoveModelApplication, RemoveModelTemplateApplication +export ReplaceModelApplicationModel, UpdateModelApplication, UpdateModelTemplateApplication +export RenameModelApplication, SetModelApplicationTargets +export SetModelInputBinding, RemoveModelInputBinding, SetModelCallBinding, RemoveModelCallBinding +export SetModelApplicationTimeStep, SetModelApplicationEnvironment +export SetModelOutputRouting, SetModelUpdateOrdering +export MarkModelPreviousTimeStep, UnmarkModelPreviousTimeStep, BreakModelCycle +export AddModelObject, RemoveModelObject, ReparentModelObject +export SetModelObjectStatus, SetModelObjectStatuses, RemoveModelObjectStatus, SetModelObjectMetadata +export SetModelInstanceOverride, RemoveModelInstanceOverride +export SetModelObjectOverride, RemoveModelObjectOverride, apply_model_graph_edit +export AbstractModelGraphEditorSession, edit_graph, current_model, apply_edit!, undo!, redo! +export AbstractEnvironmentBackend, EnvironmentSupport, GlobalConstant +export environment_backend, environment_variables, base_step_seconds +export sample, sample_environment, scatter!, update_index! +export scatter_environment_outputs! +export explain_environment +export run!, continue!, step! export fit +export Advanced # Re-exporting PlantMeteo main functions: -export Atmosphere, TimeStepTable, Constants, Weather +export Atmosphere, Constants, Weather -# Re-exporting FLoops executors: -export SequentialEx, ThreadedEx, DistributedEx end diff --git a/src/checks/dimensions.jl b/src/checks/dimensions.jl deleted file mode 100644 index 6cc929113..000000000 --- a/src/checks/dimensions.jl +++ /dev/null @@ -1,119 +0,0 @@ -""" - check_dimensions(component,weather) - check_dimensions(status,weather) - -Checks if a component status (or a status directly) and the weather have the same length, or if they can be -recycled (length 1 for one of them). - -# Examples -```jldoctest -using PlantSimEngine, PlantMeteo - -# Including an example script that implements dummy processes and models: -using PlantSimEngine.Examples - -# Creating a dummy weather: -w = Atmosphere(T = 20.0, Rh = 0.5, Wind = 1.0) - -# Creating a dummy component: -models = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=[15.0, 16.0], var2=0.3) -) - -# Checking that the number of time-steps are compatible (here, they are, it returns nothing): -PlantSimEngine.check_dimensions(models, w) - -# Creating a dummy weather with 3 time-steps: -w = Weather([ - Atmosphere(T = 20.0, Rh = 0.5, Wind = 1.0), - Atmosphere(T = 25.0, Rh = 0.5, Wind = 1.0), - Atmosphere(T = 30.0, Rh = 0.5, Wind = 1.0) -]) - -# Checking that the number of time-steps are compatible (here, they are not, it throws an error): -PlantSimEngine.check_dimensions(models, w) - -# output -ERROR: DimensionMismatch: Component status has a vector variable : var1 implying multiple timesteps but weather data only provides a single timestep. -``` -""" -check_dimensions(component, weather) = check_dimensions(DataFormat(weather), component, weather) - -# Here we add methods for applying to a component, an array or a dict of: -function check_dimensions(component::T, w) where {T<:ModelList} - check_dimensions(status(component), w) -end - -function check_dimensions(component::ModelMapping{SingleScale}, w) - check_dimensions(status(component), w) -end - -# for several components as an array -function check_dimensions(component::T, weather) where {T<:AbstractArray{<:ModelList}} - for i in component - check_dimensions(i, weather) - end -end - -# for several components as a Dict -function check_dimensions(component::T, weather) where {T<:AbstractDict{N,<:ModelList} where {N}} - for (key, val) in component - check_dimensions(val, weather) - end -end - - -# TODO multi timestep handling - -# A Status (one time-step) is always authorized with a Weather (it is recycled). -# The status is updated at each time-step, but no intermediate saving though! -function check_dimensions(::TableAlike, st::Status, weather) - weather_len = get_nsteps(weather) - - for (var, value) in zip(keys(st), st) - if length(value) > 1 - if length(value) != weather_len - throw(DimensionMismatch("Component status has a vector variable : $(var) of length $(length(value)) but the weather data expects $(weather_len) timesteps.")) - end - end - end - - return nothing -end - -function check_dimensions(::SingletonAlike, st::Status, weather) - for (var, value) in zip(keys(st), st) - if length(value) > 1 - throw(DimensionMismatch("Component status has a vector variable : $(var) implying multiple timesteps but weather data only provides a single timestep.")) - end - end - - return nothing -end - -function check_dimensions(::SingletonAlike, ::SingletonAlike, st, weather) - return nothing -end - - -""" - get_nsteps(t) - -Get the number of steps in the object. -""" -function get_nsteps(t) - get_nsteps(DataFormat(t), t) -end - -function get_nsteps(::SingletonAlike, t) - 1 -end - -function get_nsteps(::TableAlike, t) - DataAPI.nrow(t) -end - -get_nsteps(::TableAlike, t::PlantMeteo.TimeStepRows) = length(t) diff --git a/src/component_models/ModelList.jl b/src/component_models/ModelList.jl deleted file mode 100644 index 3c97efbe3..000000000 --- a/src/component_models/ModelList.jl +++ /dev/null @@ -1,471 +0,0 @@ - -""" - ModelList(models::M, status::S) - ModelList(; - status=nothing, - type_promotion=nothing, - variables_check=true, - kwargs... - ) - -List the models for a simulation (`models`), and does all boilerplate for variable initialization, -type promotion, time steps handling. - -!!! note - The status field depends on the input models. You can get the variables needed by a model - using [`variables`](@ref) on the instantiation of a model. You can also use [`inputs`](@ref) - and [`outputs`](@ref) instead. - -# Arguments - -- `models`: a list of models. Usually given as a `NamedTuple`, but can be any other structure that -implements `getproperty`. -- `status`: a structure containing the initializations for the variables of the models. Usually a NamedTuple -when given as a kwarg, or any structure that implements the Tables interface from `Tables.jl` (*e.g.* DataFrame, see details). -- `type_promotion`: optional type conversion for the variables with default values. -`nothing` by default, *i.e.* no conversion. Note that conversion is not applied to the -variables input by the user as `kwargs` (need to do it manually). -Should be provided as a Dict with current type as keys and new type as values. -- `variables_check=true`: check that all needed variables are initialized by the user. -- `kwargs`: the models, named after the process they simulate. - -# Details - -If you need to input a custom Type for the status and make your users able to only partially initialize -the `status` field in the input, you'll have to implement a method for `add_model_vars!`, a function that -adds the models variables to the type in case it is not fully initialized. The default method is compatible -with any type that implements the `Tables.jl` interface (*e.g.* DataFrame), and `NamedTuples`. - -Note that `ModelList`makes a copy of the input `status` if it does not list all needed variables. - -## Examples - -We'll use the dummy models from the `dummy.jl` in the examples folder of the package. It -implements three dummy processes: `Process1Model`, `Process2Model` and `Process3Model`, with -one model implementation each: `Process1Model`, `Process2Model` and `Process3Model`. - -```jldoctest 1 -julia> using PlantSimEngine; -``` - -Including example processes and models: - -```jldoctest 1 -julia> using PlantSimEngine.Examples; -``` - -```jldoctest 1 -julia> models = ModelList(process1=Process1Model(1.0), process2=Process2Model(), process3=Process3Model()); -[ Info: Some variables must be initialized before simulation: (process1 = (:var1, :var2), process2 = (:var1,)) (see `to_initialize()`) -``` - -```jldoctest 1 -julia> typeof(models) -ModelList{@NamedTuple{process1::Process1Model, process2::Process2Model, process3::Process3Model}, Status{(:var5, :var4, :var6, :var1, :var3, :var2), NTuple{6, Base.RefValue{Float64}}}} -``` - -No variables were given as keyword arguments, that means that the status of the ModelList is not -set yet, and all variables are initialized to their default values given in the inputs and outputs (usually `typemin(Type)`, *i.e.* `-Inf` for floating -point numbers). This component cannot be simulated yet. - -To know which variables we need to initialize for a simulation, we use [`to_initialize`](@ref): - -```jldoctest 1 -julia> to_initialize(models) -(process1 = (:var1, :var2), process2 = (:var1,)) -``` - -We can now provide values for these variables in the `status` field, and simulate the `ModelList`, -*e.g.* for `process3` (coupled with `process1` and `process2`): - -```jldoctest 1 -julia> models = ModelList(process1=Process1Model(1.0), process2=Process2Model(), process3=Process3Model(), status=(var1=15.0, var2=0.3)); -``` - -```jldoctest 1 -julia> meteo = Atmosphere(T = 22.0, Wind = 0.8333, P = 101.325, Rh = 0.4490995); -``` - -```jldoctest 1 -julia> outputs_sim = run!(models,meteo); -``` - -```jldoctest 1 -julia> outputs_sim[:var6] -1-element Vector{Float64}: - 58.0138985 -``` - -If we want to use special types for the variables, we can use the `type_promotion` argument: - -```jldoctest 1 -julia> models = ModelList(process1=Process1Model(1.0), process2=Process2Model(), process3=Process3Model(), status=(var1=15.0, var2=0.3), type_promotion = ModelMapping(Float64 => Float32)); -``` - -We used `type_promotion` to force the status into Float32: - -```jldoctest 1 -julia> [typeof(models[i][1]) for i in keys(status(models))] -6-element Vector{DataType}: - Float32 - Float32 - Float32 - Float64 - Float64 - Float32 -``` - -But we see that only the default variables (the ones that are not given in the status arguments) -were converted to Float32, the two other variables that we gave were not converted. This is -because we want to give the ability to users to give any type for the variables they provide -in the status. If we want all variables to be converted to Float32, we can pass them as Float32: - -```jldoctest 1 -julia> models = ModelList(process1=Process1Model(1.0), process2=Process2Model(), process3=Process3Model(), status=(var1=15.0f0, var2=0.3f0), type_promotion = Dict(Float64 => Float32)); -``` - -We used `type_promotion` to force the status into Float32: - -```jldoctest 1 -julia> [typeof(models[i][1]) for i in keys(status(models))] -6-element Vector{DataType}: - Float32 - Float32 - Float32 - Float32 - Float32 - Float32 -``` -""" -struct ModelList{M<:NamedTuple,S} - models::M - status::S - type_promotion::Union{Nothing,Dict} - dependency_graph::DependencyGraph -end - -#=function ModelList(models::M, status::Status) where {M<:NamedTuple{names,T} where {names,T<:NTuple{N,<:AbstractModel} where {N}}} - ModelList(models, status) -end=# - -# General interface: -function ModelList( - args...; - status=nothing, - type_promotion::Union{Nothing,Dict}=nothing, - variables_check::Bool=true, - kwargs... -) - # Get all the variables needed by the models and their default values: - if length(args) > 0 - args = parse_models(args) - else - args = NamedTuple() - end - - if length(kwargs) > 0 - kwargs = (; kwargs...) - else - kwargs = () - end - - if length(args) == 0 && length(kwargs) == 0 - error("No models were given") - end - - mods = merge(args, kwargs) - - # Make a vector of NamedTuples from the input (please implement yours if you need it) - ts_kwargs = homogeneous_ts_kwargs(status) - ts_kwargs = add_model_vars(ts_kwargs, mods, type_promotion) - - model_list = ModelList( - mods, - ts_kwargs, - type_promotion, - dep(; verbose=true, mods...) - ) - variables_check && !is_initialized(model_list) - - return model_list -end - -outputs(m::ModelList) = m.outputs - -parse_models(m) = NamedTuple([process(i) => i for i in m]) - -""" - add_model_vars(x, models, type_promotion) - -Check which variables in `x` are not initialized considering a set of `models` and the variables -needed for their simulation. If some variables are uninitialized, initialize them to their default values. - -This function needs to be implemented for each type of `x`. The default method works for -any Tables.jl-compatible `x` and for NamedTuples. - -Careful, the function makes a copy of the input `x` if it does not list all needed variables. -""" -function add_model_vars(x, models, type_promotion) - ref_vars = merge(init_variables(models; verbose=false)...) - # If no variable is required, we return the input: - length(ref_vars) == 0 && return isa(x, Status) ? x : Status(x) - - # If the user gave a status, we check if all the variables are already initialized: - vars_in_x = status_keys(x) - status_x = - all([k in vars_in_x for k in keys(ref_vars)]) && return isa(x, Status) ? x : Status(x) # If so, we return the input - - # Else, we add the variables by making a new object (carefull, this is a copy so it takes more time): - - # Convert model variables types to the one required by the user: - ref_vars = convert_vars(ref_vars, type_promotion) - - # If the user gave an empty status, we initialize all variables to their default values: - if x === nothing - return Status(ref_vars) - end - - if Tables.istable(x) - # This situation only occurs if the user provided a table instead of a status - # Meaning we have a status of vector values, all initialized up to a certain point - # Unsure this is desirable, as that means run! does nothing or overwrites everything - # Anyway, we wish to create a NamedTuple() of Vectors here - x_full = (; zip(propertynames(x), Tables.columns(x))...) - x_full = merge(ref_vars, x_full) - - else - x_full = merge(ref_vars, NamedTuple(x)) - end - #x_full = merge(ref_vars, NamedTuple(x)) - - return Status(x_full) -end - -function status_keys(st) - Tables.istable(st) && return Tables.columnnames(st) - return keys(st) -end - -status_keys(::Nothing) = NamedTuple() - -# If the user doesn't give any initializations, we initialize all variables to their default values: -function add_model_vars(x::Nothing, models, type_promotion) - ref_vars = merge(init_variables(models; verbose=false)...) - length(ref_vars) == 0 && return x - # Convert model variables types to the one required by the user: - return Status(convert_vars(ref_vars, type_promotion)) -end - -""" - homogeneous_ts_kwargs(kwargs) - -By default, the function returns its argument. -""" -homogeneous_ts_kwargs(kwargs) = kwargs - -""" - kwargs_to_timestep(kwargs::NamedTuple{N,T}) where {N,T} - -Takes a NamedTuple with optionnaly vector of values for each variable, and makes a -vector of NamedTuple, with each being a time step. -It is used to be able to *e.g.* give constant values for all time-steps for one variable. - -# Examples - -```@example -PlantSimEngine.homogeneous_ts_kwargs((Tₗ=[25.0, 26.0], aPPFD=1000.0)) -``` -""" -function homogeneous_ts_kwargs(kwargs::NamedTuple{N,T}) where {N,T} - length(kwargs) == 0 && return kwargs - vars_vals = collect(Any, values(kwargs)) - - vars_array = NamedTuple{keys(kwargs)}(j for j in vars_vals) - - return vars_array -end - -""" - Base.copy(l::ModelList) - Base.copy(l::ModelList, status) - -Copy a [`ModelList`](@ref), eventually with new values for the status. - -# Examples - -```@example -using PlantSimEngine - -# Including example processes and models: -using PlantSimEngine.Examples; - -# Create a model list: -models = ModelList( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=15.0, var2=0.3) -) - -# Copy the model list: -ml2 = copy(models) - -# Copy the model list with new status: -ml3 = copy(models, TimeStepTable([Status(var1=20.0, var2=0.5))]) -``` -""" -function Base.copy(m::T) where {T<:ModelList} - ModelList( - m.models, - deepcopy(m.status), - deepcopy(m.type_promotion), - deepcopy(m.dependency_graph) - ) -end - -function Base.copy(m::T, status) where {T<:ModelList} - ModelList( - m.models, - status, - deepcopy(m.type_promotion), - deepcopy(m.dependency_graph) - ) -end - -""" - Base.copy(l::AbstractArray{<:ModelList}) - -Copy an array-alike of [`ModelList`](@ref) -""" -function Base.copy(l::T) where {T<:AbstractArray{<:ModelList}} - return [copy(i) for i in l] -end - -""" - Base.copy(l::AbstractDict{N,<:ModelList} where N) - -Copy a Dict-alike [`ModelList`](@ref) -""" -function Base.copy(l::T) where {T<:AbstractDict{N,<:ModelList} where {N}} - return Dict([k => v for (k, v) in l]) -end - - -""" - convert_vars(ref_vars, type_promotion::Dict{DataType,DataType}) - convert_vars(ref_vars, type_promotion::Nothing) - convert_vars!(ref_vars::Dict{Symbol}, type_promotion::Dict{DataType,DataType}) - convert_vars!(ref_vars::Dict{Symbol}, type_promotion::Nothing) - -Convert the status variables to the type specified in the type promotion dictionary. -*Note: the mutating version only works with a dictionary of variables.* - -# Examples - -If we want all the variables that are Reals to be Float32, we can use: - -```julia -using PlantSimEngine - -# Including example processes and models: -using PlantSimEngine.Examples; - -ref_vars = init_variables( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), -) -type_promotion = Dict(Real => Float32) - -PlantSimEngine.convert_vars(type_promotion, ref_vars.process3) -``` -""" -convert_vars, convert_vars! - -function convert_vars(ref_vars, type_promotion::Dict{DataType,DataType}) - dict_ref_vars = Dict{Symbol,Any}(zip(keys(ref_vars), values(ref_vars))) - for (suptype, newtype) in type_promotion - vars = [] - for var in keys(ref_vars) - if isa(dict_ref_vars[var], suptype) - dict_ref_vars[var] = convert(newtype, dict_ref_vars[var]) - push!(vars, var) - end - end - # length(vars) > 1 && @info "$(join(vars, ", ")) are $suptype and were promoted to $newtype" - end - - return NamedTuple(dict_ref_vars) -end - -# Mutating version of the function, needs a dictionary of variables: -function convert_vars!(ref_vars::Dict{Symbol,Any}, type_promotion::Dict) - for (suptype, newtype) in type_promotion - for var in keys(ref_vars) - if isa(ref_vars[var], suptype) - ref_vars[var] = convert(newtype, ref_vars[var]) - elseif isa(ref_vars[var], MappedVar) && isa(mapped_default(ref_vars[var]), suptype) - ref_mapped_var = ref_vars[var] - old_default = mapped_default(ref_vars[var]) - - if isa(old_default, AbstractArray) - new_val = [convert(newtype, i) for i in old_default] - else - new_val = convert(newtype, old_default) - end - - ref_vars[var] = MappedVar( - source_organs(ref_mapped_var), - mapped_variable(ref_mapped_var), - source_variable(ref_mapped_var), - new_val, - ) - elseif isa(ref_vars[var], UninitializedVar) && isa(ref_vars[var].value, suptype) - ref_mapped_var = ref_vars[var] - old_default = ref_vars[var].value - - if isa(old_default, AbstractArray) - new_val = [convert(newtype, i) for i in old_default] - else - new_val = convert(newtype, old_default) - end - - ref_vars[var] = UninitializedVar(var, new_val) - end - end - end -end - -# This is the generic one, with no convertion: -function convert_vars(ref_vars, type_promotion::Nothing) - return ref_vars -end - -function convert_vars!(ref_vars::Dict{Symbol,Dict{Symbol,Any}}, type_promotion::Nothing) - return ref_vars -end - -""" - convert_vars!(mapped_vars::Dict{Symbol,Dict{Symbol,Any}}, type_promotion) - -Converts the types of the variables in a mapping (`mapped_vars`) using the `type_promotion` dictionary. - -The mapping should be a dictionary with organ name as keys and a dictionary of variables as values, -with variable names as symbols and variable value as value. -""" -function convert_vars!(mapped_vars::Dict{Symbol,Dict{Symbol,Any}}, type_promotion) - for (organ, vars) in mapped_vars - convert_vars!(vars, type_promotion) - end -end - -function Base.show(io::IO, m::MIME"text/plain", t::ModelList) - show(io, m, dep(t)) - println(io, "") - show(io, m, status(t)) -end - -# Short form printing (e.g. inside another object) -function Base.show(io::IO, t::ModelList) - print(io, "ModelList", (; zip(keys(t.models), typeof.(values(t.models)))...)) -end diff --git a/src/component_models/Status.jl b/src/component_models/Status.jl index 60cc07ad5..87750f31b 100644 --- a/src/component_models/Status.jl +++ b/src/component_models/Status.jl @@ -3,7 +3,7 @@ Status type used to store the values of the variables during simulation. It is mainly used as the structure to store the variables in the `TimeStepRow` of a `TimeStepTable` (see -[`PlantMeteo.jl` docs](https://palmstudio.github.io/PlantMeteo.jl/stable/)) of a [`ModelList`](@ref). +[`PlantMeteo.jl` docs](https://palmstudio.github.io/PlantMeteo.jl/stable/)). Most of the code is taken from MasonProtter/MutableNamedTuples.jl, so `Status` is a MutableNamedTuples with a few modifications, so in essence, it is a stuct that stores a `NamedTuple` of the references to the values of the variables, which makes it mutable. @@ -67,13 +67,22 @@ function Status(nt::NamedTuple{names}) where {names} Status(NamedTuple{names}(Ref.(values(nt)))) end +_status_vars(status) = getfield(status, :vars) +_status_values(status) = getindex.(values(_status_vars(status))) +_status_namedtuple(status) = NamedTuple{keys(status)}(values(status)) +_status_tuple(status) = values(status) +_status_iterate(status, iter=1) = iterate(NamedTuple(status), iter) +_status_firstindex(status) = 1 +_status_lastindex(status) = lastindex(NamedTuple(status)) +_status_indexed_iterate(status, i::Int, state=1) = Base.indexed_iterate(NamedTuple(status), i, state) + Base.keys(::Status{names}) where {names} = names -Base.values(st::Status) = getindex.(values(getfield(st, :vars))) +Base.values(st::Status) = _status_values(st) refvalues(mnt::Status) = values(getfield(mnt, :vars)) refvalue(mnt::Status, key::Symbol) = getfield(getfield(mnt, :vars), key) -Base.NamedTuple(mnt::Status) = NamedTuple{keys(mnt)}(values(mnt)) -Base.Tuple(mnt::Status) = values(mnt) +Base.NamedTuple(mnt::Status) = _status_namedtuple(mnt) +Base.Tuple(mnt::Status) = _status_tuple(mnt) function Base.show(io::IO, ::MIME"text/plain", t::Status) st_panel = Term.Panel( @@ -117,13 +126,13 @@ Base.propertynames(::Status{T,R}) where {T,R} = T Base.length(mnt::Status) = length(getfield(mnt, :vars)) Base.eltype(::Type{Status{N,T}}) where {N,T} = eltype.(eltype(T)) -Base.iterate(mnt::Status, iter=1) = iterate(NamedTuple(mnt), iter) +Base.iterate(mnt::Status, iter=1) = _status_iterate(mnt, iter) -Base.firstindex(mnt::Status) = 1 -Base.lastindex(mnt::Status) = lastindex(NamedTuple(mnt)) +Base.firstindex(mnt::Status) = _status_firstindex(mnt) +Base.lastindex(mnt::Status) = _status_lastindex(mnt) function Base.indexed_iterate(mnt::Status, i::Int, state=1) - Base.indexed_iterate(NamedTuple(mnt), i, state) + _status_indexed_iterate(mnt, i, state) end function Base.:(==)(s1::Status, s2::Status) @@ -169,4 +178,4 @@ function get_status_vector_max_length(s::Status) end end return max_len -end \ No newline at end of file +end diff --git a/src/component_models/StatusView.jl b/src/component_models/StatusView.jl deleted file mode 100644 index 1d74e099e..000000000 --- a/src/component_models/StatusView.jl +++ /dev/null @@ -1,147 +0,0 @@ -""" - StatusView(vars) - -An equivalent of the `Status` struct, but with views instead of Refs. Allows to use the same syntax as Status, but initialised with values of -other data structures present elsewhere, and that we want to update on mutation. - -Like the `Status`, `StatusView` is used to store the values of the variables during a simulation, mainly as the structure to store the variables -in the `TimeStepRow` of a `TimeStepTable` (see [`PlantMeteo.jl` docs](https://palmstudio.github.io/PlantMeteo.jl/stable/)) of a [`ModelList`](@ref). - -# Examples - -Making the reference data as an array: - -```jldoctest st1 -julia> ref_data = [13.747, 1.0, 0.03, 1500.0]; -``` - -Making a view of the reference data: - -```jldoctest st1 -ref_data_view = NamedTuple{(:Ra_SW_f, :sky_fraction, :d, :aPPFD)}(ntuple(i->view(ref_data, i), 4)) -``` - -Making the StatusView: - -```jldoctest st1 -julia> st = PlantSimEngine.StatusView(ref_data_view); -``` - -All these indexing methods are valid: - -```jldoctest st1 -julia> st[:Ra_SW_f] -13.747 -``` - -```jldoctest st1 -julia> st.Ra_SW_f -13.747 -``` - -```jldoctest st1 -julia> st[1] -13.747 -``` - -Setting a StatusView variable is very easy: - -```jldoctest st1 -julia> st[:Ra_SW_f] = 20.0 -20.0 -``` - -```jldoctest st1 -julia> st.Ra_SW_f = 21.0 -21.0 -``` - -```jldoctest st1 -julia> st[1] = 22.0 -22.0 -``` - -The reference data is updated: - -```jldoctest st1 -julia> ref_data -4-element Array{Float64,1}: - 22.0 - 1.0 - 0.03 - 1500.0 -``` -""" -struct StatusView{N,T<:Tuple{Vararg{<:SubArray}}} - vars::NamedTuple{N,T} -end - -function Base.getproperty(s::StatusView, name::Symbol) - return getfield(s, :vars)[name][] -end - -function Base.setproperty!(s::StatusView, name, value) - getfield(s, :vars)[name][] = value -end - -function Base.getindex(s::StatusView, name::Symbol) - return getfield(s, :vars)[name][] -end - -function Base.getindex(s::StatusView, i::Int) - return getfield(s, :vars)[i][] -end - -function Base.setindex!(s::StatusView, value, name) - getfield(s, :vars)[name][] = value -end - -Base.keys(::StatusView{names}) where {names} = names -Base.values(s::StatusView) = getindex.(values(getfield(s, :vars))) -Base.NamedTuple(mnt::StatusView) = NamedTuple{keys(mnt)}(values(mnt)) -Base.Tuple(mnt::StatusView) = values(mnt) - -function Base.show(io::IO, s::StatusView) - length(s) == 0 && return - print(io, "StatusView(") - for (i, (k, v)) in enumerate(getfield(s, :vars)) - print(io, k, "=", v[]) - if i < length(getfield(s, :vars)) - print(io, ", ") - end - end - print(io, ")") -end - -function Base.show(io::IO, ::MIME"text/plain", t::StatusView) - st_panel = Term.Panel( - Term.highlight(PlantMeteo.show_long_format_row(t)), - title="StatusView", - style="red", - fit=false, - ) - print(io, st_panel) -end - -# function Base.show(io::IO, ::MIME"text/html", s::StatusView) -# print(io, "StatusView(") -# for (i, (k, v)) in enumerate(getfield(s, :vars)) -# print(io, k, "=", v[]) -# if i < length(getfield(s, :vars)) -# print(io, ", ") -# end -# end -# print(io, ")") -# end - - -Base.propertynames(::StatusView{T,R}) where {T,R} = T -Base.length(mnt::StatusView) = length(getfield(mnt, :vars)) -Base.eltype(::Type{StatusView{T}}) where {T} = T -Base.iterate(mnt::StatusView, iter=1) = iterate(NamedTuple(mnt), iter) -Base.firstindex(mnt::StatusView) = 1 -Base.lastindex(mnt::StatusView) = lastindex(NamedTuple(mnt)) - -function Base.indexed_iterate(mnt::StatusView, i::Int, state=1) - Base.indexed_iterate(NamedTuple(mnt), i, state) -end \ No newline at end of file diff --git a/src/component_models/TimeStepTable.jl b/src/component_models/TimeStepTable.jl index d02253533..50e88ab8c 100644 --- a/src/component_models/TimeStepTable.jl +++ b/src/component_models/TimeStepTable.jl @@ -5,10 +5,6 @@ Method to build a `TimeStepTable` (from [PlantMeteo.jl](https://palmstudio.github.io/PlantMeteo.jl/stable/)) from a `DataFrame`, but with each row being a `Status`. -# Note - -[`ModelList`](@ref) uses `TimeStepTable{Status}` by default (see examples below). - # Examples ```julia @@ -23,19 +19,7 @@ df = DataFrame( ) TimeStepTable{Status}(df) -# A leaf with several values for at least one of its variable will automatically use -# TimeStepTable{Status} with the time steps: -models = ModelList( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=15.0, var2=0.3) -) - -# The status of the leaf is a TimeStepTable: -status(models) - -# Of course we can also create a TimeStepTable with Status manually: +# A TimeStepTable can also be built directly from Status values: TimeStepTable( [ Status(Tₗ=25.0, aPPFD=1000.0, Cₛ=400.0, Dₗ=1.0), @@ -59,4 +43,4 @@ end # # # Tables.Schema(names(m), DataType[i.types[1] for i in T.parameters[2].parameters]) # # Tables.Schema(names(m), col_types) -# end \ No newline at end of file +# end diff --git a/src/component_models/get_status.jl b/src/component_models/get_status.jl deleted file mode 100644 index 55327614a..000000000 --- a/src/component_models/get_status.jl +++ /dev/null @@ -1,104 +0,0 @@ -""" - status(m) - status(m::AbstractArray{<:ModelList}) - status(m::AbstractDict{T,<:ModelList}) - -Get a ModelList status, *i.e.* the state of the input (and output) variables. - -See also [`is_initialized`](@ref) and [`to_initialize`](@ref) - -# Examples - -```jldoctest -using PlantSimEngine - -# Including example models and processes: -using PlantSimEngine.Examples; - -# Create a ModelList -models = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status = (var1=[15.0, 16.0], var2=0.3) -); - -status(models) - -# Or just one variable: -status(models,:var1) - - -# Or the status at the ith time-step: -status(models, 2) - -# Or even more simply: -models[:var1] -# output -2-element Vector{Float64}: - 15.0 - 16.0 -``` -""" -function status(m) - m.status -end - -status(m::ModelMapping{SingleScale}) = status(m.data) -status(m::ModelMapping{SingleScale}, key::Symbol) = status(m.data, key) -status(m::ModelMapping{SingleScale}, key::T) where {T<:Integer} = status(m.data, key) - -function status(m::T) where {T<:AbstractArray{M} where {M}} - [status(i) for i in m] -end - -function status(m::T) where {T<:AbstractDict{N,M} where {N,M}} - Dict([k => status(v) for (k, v) in m]) -end - -# Status with a variable would return the variable value. -function status(m, key::Symbol) - getproperty(m.status, key) -end - -# Status with an integer returns the ith status. -function status(m, key::T) where {T<:Integer} - getindex(m.status, key) -end - -""" - getindex(component<:ModelList, key::Symbol) - getindex(component<:ModelList, key) - -Indexing a component models structure: - - with an integer, will return the status at the ith time-step - - with anything else (Symbol, String) will return the required variable from the status - -# Examples - -```julia -using PlantSimEngine - -lm = ModelList( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status = (var1=[15.0, 16.0], var2=0.3) -); - -lm[:var1] # Returns the value of the Tₗ variable -lm[2] # Returns the status at the second time-step -lm[2][:var1] # Returns the value of Tₗ at the second time-step -lm[:var1][2] # Equivalent of the above - -# output -16.0 -``` -""" -function Base.getindex(component::T, key) where {T<:ModelList} - status(component, key) -end - -function Base.setindex!(component::T, value, key) where {T<:ModelList} - setproperty!(status(component), key, value) -end diff --git a/src/composite_model/compilation.jl b/src/composite_model/compilation.jl new file mode 100644 index 000000000..202eb475c --- /dev/null +++ b/src/composite_model/compilation.jl @@ -0,0 +1,2041 @@ +struct CompiledModelApplication{S,AT,TS,CL,MO} + id::Symbol + spec::S + process::Symbol + name::Union{Nothing,Symbol} + target_ids::Vector{ObjectId} + applies_to::AT + timestep::TS + clock::CL + model_overrides::MO +end + +struct CompiledModelInputBinding{SEL,P,W,C} + application_id::Symbol + consumer_id::ObjectId + input::Symbol + selector::SEL + origin::Symbol + source_ids::Vector{ObjectId} + source_application_ids::Vector{Symbol} + source_var::Symbol + process::Union{Nothing,Symbol} + application::Union{Nothing,Symbol} + multiplicity::Symbol + policy::P + window::W + carrier_hint::Symbol + carrier::C +end + +struct CompiledModelCallBinding{NAME,SEL} + application_id::Symbol + consumer_id::ObjectId + call::Symbol + selector::SEL + origin::Symbol + callee_object_ids::Vector{ObjectId} + callee_application_ids::Vector{Symbol} + process::Union{Nothing,Symbol} + application::Union{Nothing,Symbol} + multiplicity::Symbol +end + +function CompiledModelCallBinding( + application_id, + consumer_id, + call::Symbol, + selector, + origin, + callee_object_ids, + callee_application_ids, + process, + application, + multiplicity, +) + return CompiledModelCallBinding{call,typeof(selector)}( + application_id, + consumer_id, + call, + selector, + origin, + callee_object_ids, + callee_application_ids, + process, + application, + multiplicity, + ) +end + +_compiled_call_name(::CompiledModelCallBinding{NAME}) where {NAME} = NAME + +struct CompiledEnvironmentBinding{B,C,S} + application_id::Symbol + object_id::ObjectId + provider::Symbol + backend::B + cell::C + required_inputs::Vector{Symbol} + source_inputs::Vector{Symbol} + produced_outputs::Vector{Symbol} + support::S + geometry_source_object_id::Union{Nothing,ObjectId} + geometry_source::Symbol + config::Any +end + +struct CompiledEnvironmentBindings{SC,B,I,S,C} + model::SC + bindings::B + by_target::I + samplers_by_application::S + sample_cache::C + model_revision::Int + environment_revision::Int +end + +struct CompiledCompositeModel{SC,AP,AI,ABO,IB,CB,IBI,CBI,DBI,MBI,AO,TL} + model::SC + applications::AP + applications_by_id::AI + applications_by_object::ABO + input_bindings::IB + call_bindings::CB + input_bindings_by_target::IBI + call_bindings_by_target::CBI + dynamic_input_binding_indices::DBI + model_bundles_by_target::MBI + application_order::AO + timeline::TL + revision::Int +end + +function _dynamic_binding_scale_keys(model::CompositeModel, binding) + binding.origin == :inferred_same_object && return Symbol[] + selector_criteria = criteria(binding.selector) + explicit_scope = _criteria_scope(selector_criteria) + default_scope = _default_dependency_scope(model, binding.consumer_id) + scope = isnothing(explicit_scope) ? default_scope : explicit_scope + scope isa Self && return Symbol[] + scale = _criteria_value(selector_criteria, :scale, Scale) + isnothing(scale) && return Union{Nothing,Symbol}[nothing] + return Symbol[Symbol(value) for value in (scale isa Tuple ? scale : (scale,))] +end + +function _index_dynamic_input_bindings(model::CompositeModel, bindings) + index = Dict{Union{Nothing,Symbol},Vector{Int}}() + for (binding_index, binding) in pairs(bindings) + for scale in _dynamic_binding_scale_keys(model, binding) + push!(get!(index, scale, Int[]), binding_index) + end + end + return index +end + +function _index_model_bindings(bindings, application_field::Symbol, object_field::Symbol) + grouped = Dict{Tuple{Symbol,ObjectId},Vector{Any}}() + for binding in bindings + key = ( + getproperty(binding, application_field), + getproperty(binding, object_field), + ) + push!(get!(grouped, key, Any[]), binding) + end + return Dict(key => Tuple(values) for (key, values) in grouped) +end + +""" + compile_composite_model(model[, applications...]) + +Compile model applications, selectors, value bindings, hard calls, writer +ordering, and schedules into the single Composite Model/Object runtime representation. +Most callers can use [`run!`](@ref) directly, which compiles as needed. +""" +function compile_composite_model(model::CompositeModel) + return compile_composite_model(model, model.applications) +end + +function compile_composite_model(model::CompositeModel, specs::Tuple) + return _compile_scene(model, specs) +end + +function compile_composite_model(model::CompositeModel, specs::AbstractVector) + return _compile_scene(model, Tuple(specs)) +end + +function compile_composite_model(model::CompositeModel, specs...) + return _compile_scene(model, specs) +end + +function _model_timeline(model::CompositeModel) + backend = environment_backend(model.environment) + _validate_meteo_duration(backend) + return _timeline_context(backend) +end + +function _compile_scene(model::CompositeModel, raw_specs; validate_required_inputs::Bool=true) + timeline = _model_timeline(model) + applications = _compile_model_applications(model, raw_specs, timeline) + call_bindings = _compile_model_call_bindings(model, applications) + _validate_model_call_cadences!(applications, call_bindings, timeline) + _validate_model_writers!(applications, call_bindings) + _prepare_model_output_statuses!(model, applications) + input_bindings = _compile_model_input_bindings( + model, + applications, + _manual_call_application_ids(call_bindings), + ) + _share_many_input_bindings!(model, input_bindings) + _prepare_model_bound_input_statuses!(model, applications, input_bindings) + _wire_model_input_carriers!(model, input_bindings) + validate_required_inputs && + _validate_model_required_inputs!(model, applications, input_bindings) + input_bindings_by_target = _index_model_bindings(input_bindings, :application_id, :consumer_id) + call_bindings_by_target = _index_model_bindings(call_bindings, :application_id, :consumer_id) + application_order = _compile_model_application_order(applications, input_bindings, call_bindings) + applications_by_id = Dict(application.id => application for application in applications) + model_bundles_by_target = _compile_model_model_bundles( + applications, + applications_by_id, + call_bindings_by_target, + ) + return CompiledCompositeModel( + model, + applications, + applications_by_id, + _applications_by_object(applications), + input_bindings, + call_bindings, + input_bindings_by_target, + call_bindings_by_target, + _index_dynamic_input_bindings(model, input_bindings), + model_bundles_by_target, + application_order, + timeline, + model.revision, + ) +end + +function _new_application_targets(model::CompositeModel, applications, added_ids) + targets = Dict{Symbol,Vector{ObjectId}}() + for application in applications + matched = ObjectId[ + object_id for object_id in added_ids + if _selector_matches_object_id(model, application.applies_to, object_id) + ] + isempty(matched) && continue + new_count = length(application.target_ids) + length(matched) + if (application.applies_to isa One && new_count != 1) || + (application.applies_to isa OptionalOne && new_count > 1) + return nothing + end + targets[application.id] = matched + end + return targets +end + +function _compile_added_consumer_bindings!( + bindings, + model, + application, + consumer_id, + manual_application_ids, + applications_by_object, + applications_by_id, +) + declared_inputs = value_inputs(application.spec) + declared_inputs isa NamedTuple || (declared_inputs = NamedTuple()) + for (input_name, selector) in pairs(declared_inputs) + input_sym = Symbol(input_name) + origin = get(input_origins(application.spec), input_sym, :model_spec) + _validate_declared_model_input_name!(application, input_sym) + _push_model_input_binding!( + bindings, + model, + application, + consumer_id, + input_sym, + selector, + origin, + applications_by_object, + applications_by_id, + ) + end + application.id in manual_application_ids && return bindings + _append_inferred_model_input_bindings!( + bindings, + model, + application, + consumer_id, + declared_inputs, + applications_by_object, + applications_by_id, + ) + return bindings +end + +function _many_binding_scope_anchor(model::CompositeModel, binding::CompiledModelInputBinding) + selector_criteria = criteria(binding.selector) + !isnothing(_criteria_get(selector_criteria, :relation, nothing)) && + return (:consumer, binding.consumer_id) + explicit_scope = _criteria_scope(selector_criteria) + scope = isnothing(explicit_scope) ? + _default_dependency_scope(model, binding.consumer_id) : explicit_scope + if isnothing(scope) || scope isa SceneScope + return (:scene,) + elseif scope isa SelfPlant + return (:plant, _ancestor_id(model, binding.consumer_id; scale=:Plant)) + elseif scope isa Scope + return (:scope, scope.name) + elseif scope isa Ancestor + return ( + :ancestor, + _ancestor_id( + model, + binding.consumer_id; + scale=scope.scale, + include_self=false, + ), + ) + end + return (:consumer, binding.consumer_id) +end + +function _many_binding_share_key(model::CompositeModel, binding::CompiledModelInputBinding) + binding.multiplicity == :many || return nothing + isnothing(binding.carrier) && return nothing + return ( + binding.selector, + _many_binding_scope_anchor(model, binding), + binding.source_var, + binding.process, + binding.application, + binding.carrier_hint, + typeof(binding.carrier), + ) +end + +function _binding_with_shared_many_sources( + binding::CompiledModelInputBinding, + canonical::CompiledModelInputBinding, +) + return CompiledModelInputBinding( + binding.application_id, + binding.consumer_id, + binding.input, + binding.selector, + binding.origin, + canonical.source_ids, + canonical.source_application_ids, + binding.source_var, + binding.process, + binding.application, + binding.multiplicity, + binding.policy, + binding.window, + binding.carrier_hint, + canonical.carrier, + ) +end + +function _share_many_input_binding!(cache, model::CompositeModel, binding) + key = _many_binding_share_key(model, binding) + isnothing(key) && return binding + canonical = get(cache, key, nothing) + if isnothing(canonical) + cache[key] = binding + return binding + end + if canonical.source_ids != binding.source_ids || + canonical.source_application_ids != binding.source_application_ids + cache[(key, binding.consumer_id)] = binding + return binding + end + return _binding_with_shared_many_sources(binding, canonical) +end + +function _share_many_input_bindings!(model::CompositeModel, bindings; cache=Dict{Any,Any}()) + for index in eachindex(bindings) + bindings[index] = _share_many_input_binding!(cache, model, bindings[index]) + end + return cache +end + +function _many_input_binding_cache(model::CompositeModel, bindings) + cache = Dict{Any,Any}() + for binding in bindings + key = _many_binding_share_key(model, binding) + isnothing(key) || haskey(cache, key) || (cache[key] = binding) + end + return cache +end + +function _append_added_many_sources!( + model::CompositeModel, + binding::CompiledModelInputBinding, + added_ids, + applications_by_object, +) + binding.multiplicity == :many || return false + default_scope = _default_dependency_scope(model, binding.consumer_id) + new_source_ids = ObjectId[ + object_id for object_id in added_ids if + _selector_matches_object_id( + model, + binding.selector, + object_id; + context=binding.consumer_id, + default_to_context=true, + default_scope=default_scope, + ) && !(object_id in binding.source_ids) + ] + isempty(new_source_ids) && return true + _sort_object_ids!(new_source_ids) + + # The growth path allocates monotonically increasing IDs. Appending preserves the + # selector's stable order and, critically, keeps the carrier already installed in + # the consumer status. Non-monotonic explicit IDs use the general rebuild path. + if !isempty(binding.source_ids) && + !_object_id_isless(last(binding.source_ids), first(new_source_ids)) + return false + end + + new_carrier = _input_carrier(model, binding.selector, new_source_ids, binding.source_var) + isnothing(new_carrier) && return false + existing_refs = parent(binding.carrier) + new_refs = parent(new_carrier) + eltype(new_refs) <: eltype(existing_refs) || return false + append!(existing_refs, new_refs) + append!(binding.source_ids, new_source_ids) + + new_application_ids = _matching_input_source_applications( + applications_by_object, + new_source_ids, + binding.source_var, + binding.process, + binding.application; + allow_empty=binding.selector isa OptionalOne, + ) + for application_id in new_application_ids + application_id in binding.source_application_ids || + push!(binding.source_application_ids, application_id) + end + return true +end + +function _extend_compiled_scene(model::CompositeModel, compiled::CompiledCompositeModel, added_objects) + added_ids = ObjectId[id for id in added_objects if haskey(model.registry.objects, id)] + isempty(added_ids) && return compile_composite_model(model, model.applications) + new_targets = _new_application_targets(model, compiled.applications, added_ids) + isnothing(new_targets) && return compile_composite_model(model, model.applications) + + for application in compiled.applications + append!(application.target_ids, get(new_targets, application.id, ObjectId[])) + _sort_object_ids!(application.target_ids) + end + applications = compiled.applications + applications_by_id = Dict(application.id => application for application in applications) + applications_by_object = compiled.applications_by_object + for application in applications + for object_id in get(new_targets, application.id, ObjectId[]) + push!(get!(applications_by_object, object_id, Any[]), application) + end + end + + has_calls = any(applications) do application + calls = model_calls(application.spec) + calls isa NamedTuple && !isempty(keys(calls)) + end + timeline = _model_timeline(model) + added_applications = CompiledModelApplication[] + for application in applications + target_ids = get(new_targets, application.id, ObjectId[]) + isempty(target_ids) && continue + push!( + added_applications, + CompiledModelApplication( + application.id, + application.spec, + application.process, + application.name, + target_ids, + application.applies_to, + application.timestep, + application.clock, + application.model_overrides, + ), + ) + end + existing_calls_affected = false + if has_calls + existing_calls_affected = any(compiled.call_bindings) do binding + _selector_matches_any_object_id( + model, + binding.selector, + added_ids; + context=binding.consumer_id, + default_to_context=true, + default_scope=_default_dependency_scope(model, binding.consumer_id), + ) + end + end + new_call_bindings = has_calls ? + _compile_model_call_bindings( + model, + added_applications, + applications, + ; + by_object=applications_by_object, + ) : CompiledModelCallBinding[] + call_bindings = if existing_calls_affected + _compile_model_call_bindings( + model, + applications; + by_object=applications_by_object, + ) + else + bindings = copy(compiled.call_bindings) + append!(bindings, new_call_bindings) + bindings + end + _validate_model_call_cadences!(applications, call_bindings, timeline) + _validate_model_writers!(added_applications, call_bindings) + _prepare_model_output_statuses!(model, added_applications) + + manual_application_ids = _manual_call_application_ids(call_bindings) + input_bindings = copy(compiled.input_bindings) + input_bindings_by_target = copy(compiled.input_bindings_by_target) + changed_bindings = CompiledModelInputBinding[] + processed_many_sources = IdDict{Any,Nothing}() + candidate_binding_indices = Set(get(compiled.dynamic_input_binding_indices, nothing, Int[])) + for object_id in added_ids + object_scale = _model_object(model, object_id).scale + isnothing(object_scale) || union!( + candidate_binding_indices, + get(compiled.dynamic_input_binding_indices, object_scale, Int[]), + ) + end + for index in candidate_binding_indices + binding = input_bindings[index] + if binding.multiplicity == :many && haskey(processed_many_sources, binding.source_ids) + continue + end + default_scope = _default_dependency_scope(model, binding.consumer_id) + _selector_matches_any_object_id( + model, + binding.selector, + added_ids; + context=binding.consumer_id, + default_to_context=true, + default_scope=default_scope, + ) || continue + _append_added_many_sources!( + model, + binding, + added_ids, + applications_by_object, + ) && begin + processed_many_sources[binding.source_ids] = nothing + continue + end + application = applications_by_id[binding.application_id] + replacement = CompiledModelInputBinding[] + _push_model_input_binding!( + replacement, + model, + application, + binding.consumer_id, + binding.input, + binding.selector, + binding.origin, + applications_by_object, + applications_by_id, + ) + replacement_binding = only(replacement) + input_bindings[index] = replacement_binding + push!(changed_bindings, replacement_binding) + target = (binding.application_id, binding.consumer_id) + input_bindings_by_target[target] = Tuple( + existing === binding ? replacement_binding : existing + for existing in get(input_bindings_by_target, target, ()) + ) + end + previous_binding_count = length(input_bindings) + many_binding_cache = _many_input_binding_cache(model, input_bindings) + for application in applications + for consumer_id in get(new_targets, application.id, ObjectId[]) + first_new_binding = length(input_bindings) + 1 + _compile_added_consumer_bindings!( + input_bindings, + model, + application, + consumer_id, + manual_application_ids, + applications_by_object, + applications_by_id, + ) + last_new_binding = length(input_bindings) + if first_new_binding <= last_new_binding + for binding_index in first_new_binding:last_new_binding + input_bindings[binding_index] = _share_many_input_binding!( + many_binding_cache, + model, + input_bindings[binding_index], + ) + end + new_bindings = input_bindings[first_new_binding:last_new_binding] + append!(changed_bindings, new_bindings) + input_bindings_by_target[(application.id, consumer_id)] = Tuple(new_bindings) + end + end + end + dynamic_input_binding_indices = Dict{Union{Nothing,Symbol},Vector{Int}}( + scale => copy(indices) + for (scale, indices) in compiled.dynamic_input_binding_indices + ) + for binding_index in (previous_binding_count + 1):length(input_bindings) + binding = input_bindings[binding_index] + for scale in _dynamic_binding_scale_keys(model, binding) + push!(get!(dynamic_input_binding_indices, scale, Int[]), binding_index) + end + end + + _prepare_model_bound_input_statuses!(model, added_applications, changed_bindings) + _wire_model_input_carriers!(model, changed_bindings) + _validate_model_required_inputs!(model, added_applications, changed_bindings) + call_bindings_by_target = if existing_calls_affected + _index_model_bindings(call_bindings, :application_id, :consumer_id) + elseif isempty(new_call_bindings) + compiled.call_bindings_by_target + else + indexed = copy(compiled.call_bindings_by_target) + merge!( + indexed, + _index_model_bindings(new_call_bindings, :application_id, :consumer_id), + ) + indexed + end + application_order = compiled.application_order + model_bundles_by_target = if existing_calls_affected + _compile_model_model_bundles( + applications, + applications_by_id, + call_bindings_by_target, + ) + else + bundles = copy(compiled.model_bundles_by_target) + for application in added_applications + for object_id in application.target_ids + bundles[(application.id, object_id)] = _compile_model_model_bundle( + applications_by_id, + call_bindings_by_target, + application, + object_id, + ) + end + end + bundles + end + return CompiledCompositeModel( + model, + applications, + applications_by_id, + applications_by_object, + input_bindings, + call_bindings, + input_bindings_by_target, + call_bindings_by_target, + dynamic_input_binding_indices, + model_bundles_by_target, + application_order, + timeline, + model.revision, + ) +end + +function _validate_model_call_cadences!(applications, call_bindings, timeline) + applications_by_id = Dict(application.id => application for application in applications) + for binding in call_bindings + caller = applications_by_id[binding.application_id] + for callee_id in binding.callee_application_ids + callee = applications_by_id[callee_id] + # A call-only target with no model/scenario cadence declaration + # inherits the cadence of its parent call. An explicit target + # cadence is a scientific contract and must match the caller. + _runtime_clock_source_for_spec(callee.spec) == :meteo_base_step && + continue + same_dt = isapprox( + float(caller.clock.dt), + float(callee.clock.dt); + atol=1.0e-9, + rtol=0.0, + ) + same_phase = isapprox( + float(caller.clock.phase), + float(callee.clock.phase); + atol=1.0e-9, + rtol=0.0, + ) + same_dt && same_phase && continue + caller_seconds = float(caller.clock.dt) * timeline.base_step_seconds + callee_seconds = float(callee.clock.dt) * timeline.base_step_seconds + error( + "Hard call `$(binding.call)` from application `$(caller.id)` to ", + "application `$(callee.id)` has incompatible cadence: caller=", + "$(caller_seconds) seconds (phase=$(caller.clock.phase)), target=", + "$(callee_seconds) seconds (phase=$(callee.clock.phase)). ", + "Use matching TimeStep declarations or omit TimeStep on the ", + "manual-call-only target so it inherits the parent call cadence." + ) + end + end + return nothing +end + +""" + explain_initialization(model::CompositeModel) + +Return structured rows describing how every application variable is +initialized. `disposition` is one of: + +- `:supplied`: present on the object's status before compilation; +- `:generated`: created from a model output declaration; +- `:producer_bound`: connected through an explicit or inferred `Inputs` binding; +- `:environment_bound`: provided by the selected environment backend; +- `:unresolved`: still requires user or scenario configuration. + +Unlike [`compile_composite_model`](@ref), this report does not fail solely because a +required status or environment value is unresolved. Selector, writer, call, +and other invalid configuration errors remain errors. +""" +function explain_initialization(model::CompositeModel) + supplied = Dict( + object.id => Set{Symbol}( + object.status isa Status ? Symbol.(propertynames(object.status)) : Symbol[] + ) + for object in values(model.registry.objects) + ) + compiled = _compile_scene( + model, + Tuple(model.applications); + validate_required_inputs=false, + ) + bindings = Dict( + (binding.application_id, binding.consumer_id, binding.input) => binding + for binding in compiled.input_bindings + ) + + environment_bindings = Dict( + (binding.application_id, binding.object_id) => binding + for binding in _compile_environment_bindings(model, compiled) + ) + + rows = NamedTuple[] + for application in compiled.applications + model_outputs = outputs_(application.spec) + meteo_model_outputs = meteo_outputs_(application.spec) + model_inputs = inputs_(application.spec) + environment_inputs = meteo_inputs_(application.spec) + generated = Set(Symbol.(keys(model_outputs))) + union!(generated, Symbol.(keys(meteo_outputs_(application.spec)))) + for object_id in application.target_ids + for variable in sort!(collect(generated); by=string) + default_value = haskey(model_outputs, variable) ? + getproperty(model_outputs, variable) : + getproperty(meteo_model_outputs, variable) + push!(rows, ( + application_id=application.id, + object_id=object_id.value, + variable=variable, + role=:output, + disposition=:generated, + source_application_ids=Symbol[], + source_object_ids=Any[], + source_variable=nothing, + origin=:model_output, + expected_type=typeof(default_value), + default_value=default_value, + provided_type=nothing, + detail=nothing, + )) + end + for variable in sort!(Symbol.(collect(keys(inputs_(application.spec)))); by=string) + key = (application.id, object_id, variable) + binding = get(bindings, key, nothing) + disposition = if !isnothing(binding) + :producer_bound + elseif variable in get(supplied, object_id, Set{Symbol}()) + :supplied + else + :unresolved + end + default_value = getproperty(model_inputs, variable) + object = _model_object(model, object_id) + provided_type = if disposition == :supplied + typeof(getproperty(object.status, variable)) + else + nothing + end + push!(rows, ( + application_id=application.id, + object_id=object_id.value, + variable=variable, + role=:input, + disposition=disposition, + source_application_ids=isnothing(binding) ? Symbol[] : copy(binding.source_application_ids), + source_object_ids=isnothing(binding) ? Any[] : [id.value for id in binding.source_ids], + source_variable=isnothing(binding) ? nothing : binding.source_var, + origin=isnothing(binding) ? + (disposition == :supplied ? :status : :missing) : + binding.origin, + expected_type=typeof(default_value), + default_value=default_value, + provided_type=provided_type, + detail=disposition == :unresolved ? + "Provide `$(variable)` on object `$(object_id.value)` Status or add `Inputs(:$(variable) => ...)` to application `$(application.id)`." : + nothing, + )) + end + for variable in sort!(Symbol.(collect(keys(meteo_inputs_(application.spec)))); by=string) + environment_binding = get( + environment_bindings, + (application.id, object_id), + nothing, + ) + source = get( + _environment_source_overrides(application.spec), + variable, + variable, + ) + available = isnothing(environment_binding) ? + Set{Symbol}() : + environment_variables(environment_binding.backend) + bound = isnothing(available) || Symbol(source) in available + default_value = getproperty(environment_inputs, variable) + push!(rows, ( + application_id=application.id, + object_id=object_id.value, + variable=variable, + role=:environment_input, + disposition=bound ? :environment_bound : :unresolved, + source_application_ids=Symbol[], + source_object_ids=Any[], + source_variable=Symbol(source), + origin=:environment, + expected_type=typeof(default_value), + default_value=default_value, + provided_type=nothing, + detail=bound ? nothing : + "Environment source `$(source)` is not available for this application/object.", + )) + end + end + end + sort!(rows; by=row -> ( + string(row.application_id), + string(row.object_id), + string(row.role), + string(row.variable), + )) + return rows +end + +function _compile_model_applications(model::CompositeModel, raw_specs, timeline) + specs = [as_model_spec(raw_spec) for raw_spec in raw_specs] + process_counts = Dict{Symbol,Int}() + for spec in specs + proc = process(spec) + process_counts[proc] = get(process_counts, proc, 0) + 1 + end + ids = Set{Symbol}() + applications = CompiledModelApplication[] + for spec in specs + selector = applies_to(spec) + isnothing(selector) && error( + "Model application for process `$(process(spec))` has no `AppliesTo(...)` selector." + ) + selector isa AbstractObjectMultiplicity || error( + "`AppliesTo(...)` for process `$(process(spec))` must be an object selector such as `Many(scale=:Leaf)`." + ) + proc = process(spec) + name = application_name(spec) + if isnothing(name) && process_counts[proc] > 1 + error( + "Composite model contains $(process_counts[proc]) unnamed applications for process `$(proc)`. ", + "Give every repeated application a unique name with `ModelSpec(model; name=:application_name)`.", + ) + end + app_id = isnothing(name) ? proc : name + app_id in ids && error("Duplicate compiled model application id `$(app_id)`.") + push!(ids, app_id) + target_ids = resolve_object_ids(model, selector) + spec = _model_spec_with_meteo_hints( + model, + spec, + _model_application_hint_scale(model, target_ids), + ) + model_overrides = _compiled_object_model_overrides(spec, target_ids, app_id) + push!( + applications, + CompiledModelApplication( + app_id, + spec, + proc, + name, + target_ids, + selector, + timestep(spec), + _model_application_clock(model, spec, target_ids, timeline), + model_overrides, + ), + ) + end + return applications +end + +function _model_spec_with_meteo_hints(model::CompositeModel, spec, scale::Symbol) + hint = _normalize_meteo_hint(scale, process(spec), meteo_hint(model_(spec))) + + current_bindings = meteo_bindings(spec) + has_explicit_bindings = !(current_bindings isa NamedTuple && isempty(keys(current_bindings))) + new_bindings = has_explicit_bindings || isnothing(hint.bindings) ? current_bindings : hint.bindings + new_bindings = _model_meteo_bindings_with_environment_sources(spec, new_bindings) + + current_window = meteo_window(spec) + new_window = isnothing(current_window) && !isnothing(hint.window) ? hint.window : current_window + + (new_bindings === current_bindings && new_window === current_window) && return spec + return ModelSpec(spec; meteo_bindings=new_bindings, meteo_window=new_window) +end + +function _model_meteo_bindings_with_environment_sources(spec, bindings) + sources = _environment_source_overrides(spec) + isempty(keys(sources)) && return bindings + + bindings = bindings isa NamedTuple ? bindings : NamedTuple() + model_inputs = Set(Symbol.(keys(meteo_inputs_(spec)))) + unknown = Symbol[target for target in keys(sources) if !(Symbol(target) in model_inputs)] + isempty(unknown) || error( + "`Environment(; sources=...)` for process `$(process(spec))` contains ", + "unknown model-facing meteo input(s) `$(Tuple(unknown))`. Declared ", + "`meteo_inputs_` are `$(Tuple(sort!(collect(model_inputs); by=string)))`." + ) + + targets = Symbol[Symbol(target) for target in keys(bindings)] + for target in keys(sources) + target = Symbol(target) + target in targets || push!(targets, target) + end + + resolved = Pair{Symbol,Any}[] + for target in targets + rule = haskey(bindings, target) ? + _normalize_meteo_binding_rule(target, bindings[target]) : + (source=target, reducer=PlantMeteo.MeanWeighted()) + source = haskey(sources, target) ? Symbol(sources[target]) : rule.source + push!(resolved, target => (source=source, reducer=rule.reducer)) + end + return (; resolved...) +end + +function _compiled_object_model_overrides(spec, target_ids, application_id::Symbol) + model = model_(spec) + model isa ObjectModelOverrides || return nothing + target_set = Set(target_ids) + unmatched = ObjectId[id for id in keys(model.overrides) if !(id in target_set)] + isempty(unmatched) || error( + "Object override(s) `$([id.value for id in unmatched])` for application ", + "`$(application_id)` do not match its `AppliesTo(...)` target set." + ) + return model.overrides +end + +_application_default_model(application::CompiledModelApplication) = + model_(application.spec) isa ObjectModelOverrides ? + model_(application.spec).base : + model_(application.spec) + +function _application_model(application::CompiledModelApplication, object_id::ObjectId) + isnothing(application.model_overrides) && return _application_default_model(application) + return get( + application.model_overrides, + object_id, + _application_default_model(application), + ) +end + +function _model_output_names(application::CompiledModelApplication) + return Symbol[Symbol(var) for var in keys(outputs_(application.spec))] +end + +function _model_canonical_output_names(application::CompiledModelApplication) + return Symbol[ + variable for variable in _model_output_names(application) + if _publish_mode_for_output(application.spec, variable) == :canonical + ] +end + +function _model_writer_groups(applications, skipped_application_ids=Set{Symbol}()) + groups = Dict{Tuple{ObjectId,Symbol},Vector{Tuple{Int,Any}}}() + for (index, application) in pairs(applications) + application.id in skipped_application_ids && continue + for object_id in application.target_ids + for variable in _model_canonical_output_names(application) + push!(get!(groups, (object_id, variable), Tuple{Int,Any}[]), (index, application)) + end + end + end + return groups +end + +function _application_match_labels(application::CompiledModelApplication) + labels = Set{Symbol}([application.id]) + isnothing(application.name) || push!(labels, application.name) + return labels +end + +function _update_matches_application(label::Symbol, application::CompiledModelApplication) + label == application.id && return true + if label == application.process || (!isnothing(application.name) && label == application.name) + Base.depwarn( + "Matching `Updates(...; after=$(repr(label)))` by process or local name is deprecated. " * + "Use the canonical application identifier `$(application.id)`.", + :Updates, + ) + return true + end + return false +end + +function _update_variables(update) + return Tuple(Symbol(variable) for variable in getproperty(update, :variables)) +end + +function _update_after(update) + return Tuple(Symbol(label) for label in getproperty(update, :after)) +end + +function _matching_updates(spec, variable::Symbol) + return [update for update in updates(spec) if variable in _update_variables(update)] +end + +function _update_after_labels(spec, variable::Symbol) + labels = Symbol[] + for update in _matching_updates(spec, variable) + append!(labels, _update_after(update)) + end + unique!(labels) + return labels +end + +function _updates_after_previous_writer(spec, variable::Symbol, previous_applications) + matching = _matching_updates(spec, variable) + isempty(matching) && return false + for update in matching + after = _update_after(update) + isempty(after) && continue + any( + label -> any(application -> _update_matches_application(label, application), previous_applications), + after, + ) && return true + end + return false +end + +function _declares_update_without_previous_writer(spec, variable::Symbol, previous_applications) + isempty(previous_applications) || return false + for update in _matching_updates(spec, variable) + isempty(_update_after(update)) || return true + end + return false +end + +function _manual_call_application_ids(call_bindings) + ids = Set{Symbol}() + for binding in call_bindings + union!(ids, binding.callee_application_ids) + end + return ids +end + +function _validate_model_writers!(applications, call_bindings=()) + manual_application_ids = _manual_call_application_ids(call_bindings) + for ((object_id, variable), indexed_writers) in _model_writer_groups(applications, manual_application_ids) + length(indexed_writers) <= 1 && continue + sort!(indexed_writers; by=first) + previous = CompiledModelApplication[] + for (_, application) in indexed_writers + if _declares_update_without_previous_writer(application.spec, variable, previous) + error( + "Application `$(application.id)` declares `Updates($(variable))` for object ", + "`$(object_id.value)`, but no previous writer for `$(variable)` exists. ", + "Move it after the producer named in `after=...`." + ) + end + if !isempty(previous) && isempty(_matching_updates(application.spec, variable)) + previous_labels = sort!(collect(reduce(union!, (_application_match_labels(app) for app in previous); init=Set{Symbol}()))) + error( + "Ambiguous canonical writers for variable `$(variable)` on object ", + "`$(object_id.value)`. ", + "applications. Application `$(application.id)` must declare ", + "`Updates(:$(variable); after=...)` matching one of the previous writers ", + "`$(previous_labels)`." + ) + end + if !isempty(previous) && + !_updates_after_previous_writer( + application.spec, + variable, + CompiledModelApplication[last(previous)], + ) + previous_writer = last(previous) + error( + "Application `$(application.id)` updates `$(variable)` on object ", + "`$(object_id.value)` without an ordering relation to the immediately ", + "previous writer `$(previous_writer.id)`. Add that application identifier ", + "to `Updates(:$(variable); after=...)`." + ) + end + push!(previous, application) + end + end + return nothing +end + +function _model_application_hint_scale(model::CompositeModel, target_ids::Vector{ObjectId}) + isempty(target_ids) && return :Scene + scales = unique!([_model_object(model, object_id).scale for object_id in target_ids]) + length(scales) == 1 && return only(scales) + return :Mixed +end + +function _model_application_clock(model::CompositeModel, spec, target_ids::Vector{ObjectId}, timeline) + process_model = model_(spec) + source = _runtime_clock_source_for_spec(spec) + source == :meteo_base_step || return _model_clock(spec, process_model, timeline) + scale = _model_application_hint_scale(model, target_ids) + clock, hint_reason = + _resolve_meteo_hint_clock(scale, process(spec), process_model, timeline) + isnothing(hint_reason) || error(hint_reason) + return clock +end + +function _criteria_get(criteria, key::Symbol, default=nothing) + return haskey(criteria, key) ? getproperty(criteria, key) : default +end + +function _selector_policy(selector::AbstractObjectMultiplicity) + return _criteria_get(criteria(selector), :policy, HoldLast()) +end + +_selector_has_policy(selector::AbstractObjectMultiplicity) = + haskey(criteria(selector), :policy) + +function _model_policy_from_source_application(applications_by_id, application_id::Symbol, source_var::Symbol) + application = get(applications_by_id, application_id, nothing) + isnothing(application) && error( + "No compiled model application with id `$(application_id)` while resolving ", + "default output policy for `$(source_var)`." + ) + return _policy_for_output(_application_default_model(application), source_var) +end + +function _model_selector_policy(selector::AbstractObjectMultiplicity, applications_by_id, source_application_ids, source_var::Symbol) + if _selector_has_policy(selector) + policy = _selector_policy(selector) + policy isa PreviousTimeStep && return policy + return _as_schedule_policy( + policy; + context="model input policy for `$(source_var)`", + ) + end + isempty(source_application_ids) && return HoldLast() + length(source_application_ids) == 1 && return _model_policy_from_source_application( + applications_by_id, + only(source_application_ids), + source_var, + ) + policies = [ + _model_policy_from_source_application(applications_by_id, application_id, source_var) + for application_id in source_application_ids + ] + first_policy = first(policies) + all(policy -> policy == first_policy, policies) && return first_policy + error( + "Cannot infer default policy for model input from `$(source_var)` because ", + "selector resolves several source applications `$(source_application_ids)` with ", + "different output policies. Add `policy=...` to `Inputs(...)`." + ) +end + +function _selector_window(selector::AbstractObjectMultiplicity) + return _criteria_get(criteria(selector), :window, nothing) +end + +function _selector_var(selector::AbstractObjectMultiplicity, fallback::Symbol) + return _criteria_get(criteria(selector), :var, fallback) +end + +function _selector_application(selector::AbstractObjectMultiplicity) + return _criteria_get(criteria(selector), :application, nothing) +end + +function _dependency_object_ids(model::CompositeModel, selector::AbstractObjectMultiplicity, context::ObjectId) + return _resolve_object_ids( + model, + selector; + context=context, + default_to_context=true, + default_scope=_default_dependency_scope(model, context), + ) +end + +function _carrier_hint(selector::AbstractObjectMultiplicity, policy, window) + !isnothing(window) && return :temporal_stream + policy isa HoldLast || return :temporal_stream + selector isa Many && return :ref_vector + return :shared_ref +end + +function _status_ref_or_nothing(status, var::Symbol) + status isa Status || return nothing + var in propertynames(status) || return nothing + return refvalue(status, var) +end + +function _input_carrier(model::CompositeModel, selector::AbstractObjectMultiplicity, source_ids::Vector{ObjectId}, source_var::Symbol) + refs = Base.RefValue[] + for source_id in source_ids + object = _model_object(model, source_id) + source_ref = _status_ref_or_nothing(object.status, source_var) + isnothing(source_ref) && return nothing + push!(refs, source_ref) + end + if selector isa Many + isempty(refs) && return RefVector{Any}() + return _ref_vector_carrier(refs) + end + return isempty(refs) ? nothing : only(refs) +end + +function _ref_vector_carrier(refs) + T = typeof(refs[1][]) + typed_refs = Base.RefValue{T}[] + for source_ref in refs + source_ref isa Base.RefValue{T} || return ObjectRefVector(refs) + push!(typed_refs, source_ref) + end + return RefVector(typed_refs) +end + +function _status_with_reference(status::Status, variable::Symbol, reference::Base.RefValue) + names = propertynames(status) + if variable in names + references = ntuple( + index -> names[index] == variable ? reference : refvalue(status, names[index]), + length(names), + ) + return Status(NamedTuple{names}(references)) + end + extended_names = (names..., variable) + references = (ntuple(index -> refvalue(status, names[index]), length(names))..., reference) + return Status(NamedTuple{extended_names}(references)) +end + +function _status_with_default(status::Status, variable::Symbol, value) + variable in propertynames(status) && return status + return _status_with_reference(status, variable, Ref(value)) +end + +function _ensure_model_object_status!(model::CompositeModel, object_id::ObjectId) + object = _model_object(model, object_id) + isnothing(object.status) && (object.status = Status()) + object.status isa Status || error( + "Model object `$(object_id.value)` uses model applications but its status has type ", + "`$(typeof(object.status))`. Use `Status(...)` or leave status as `nothing`." + ) + return object.status +end + +function _prepare_model_output_statuses!(model::CompositeModel, applications) + for application in applications + defaults = merge(outputs_(application.spec), meteo_outputs_(application.spec)) + for object_id in application.target_ids + status = _ensure_model_object_status!(model, object_id) + for (variable, value) in pairs(defaults) + status = _status_with_default(status, Symbol(variable), value) + end + _model_object(model, object_id).status = status + end + end + return model +end + +function _prepare_model_bound_input_statuses!(model::CompositeModel, applications, bindings) + applications_by_id = Dict(application.id => application for application in applications) + for binding in bindings + status = _ensure_model_object_status!(model, binding.consumer_id) + binding.input in propertynames(status) && continue + application = applications_by_id[binding.application_id] + defaults = inputs_(application.spec) + binding.input in keys(defaults) || error( + "Bound input `$(binding.input)` is not declared by application `$(binding.application_id)`." + ) + _model_object(model, binding.consumer_id).status = + _status_with_default(status, binding.input, getproperty(defaults, binding.input)) + end + return model +end + +function _wire_model_input_carriers!(model::CompositeModel, bindings) + for binding in bindings + binding.carrier_hint == :temporal_stream && continue + isnothing(binding.carrier) && continue + object = _model_object(model, binding.consumer_id) + status = object.status + status isa Status || continue + reference = binding.carrier isa Base.RefValue ? binding.carrier : Ref(binding.carrier) + object.status = _status_with_reference(status, binding.input, reference) + end + return model +end + +input_carrier(binding::CompiledModelInputBinding) = binding.carrier +has_reference_carrier(binding::CompiledModelInputBinding) = !isnothing(binding.carrier) +input_value(binding::CompiledModelInputBinding) = _input_value(binding.carrier) +_input_value(::Nothing) = nothing +_input_value(carrier::Base.RefValue) = carrier[] +_input_value(carrier::RefVector) = carrier +_input_value(carrier::ObjectRefVector) = carrier + +function _matching_input_source_applications( + applications_by_object, + source_ids, + source_var::Symbol, + process_filter, + application_filter; + allow_empty::Bool=false, +) + matches = Symbol[] + for source_id in source_ids + for application in get(applications_by_object, source_id, Any[]) + source_var in _model_output_names(application) || continue + isnothing(process_filter) || application.process == process_filter || continue + isnothing(application_filter) || application.id == application_filter || continue + push!(matches, application.id) + end + end + unique!(matches) + if !allow_empty && + (!isnothing(process_filter) || !isnothing(application_filter)) && + isempty(matches) + error( + "Input selector for source variable `$(source_var)` requested", + isnothing(process_filter) ? "" : " process `$(process_filter)`", + isnothing(application_filter) ? "" : " application `$(application_filter)`", + ", but no matching source application was found." + ) + end + return matches +end + +function _final_canonical_source_application( + applications_by_object, + source_ids, + source_application_ids, + source_var::Symbol, +) + length(source_ids) == 1 || return source_application_ids + matching_ids = Set(source_application_ids) + canonical_ids = Symbol[ + application.id + for application in get(applications_by_object, only(source_ids), Any[]) + if application.id in matching_ids && + _publish_mode_for_output(application.spec, source_var) == :canonical + ] + isempty(canonical_ids) && return source_application_ids + return Symbol[last(canonical_ids)] +end + +function _compile_model_input_bindings( + model::CompositeModel, + applications, + manual_application_ids=Set{Symbol}(), +) + bindings = CompiledModelInputBinding[] + by_object = _applications_by_object(applications) + by_id = Dict(application.id => application for application in applications) + for application in applications + for consumer_id in application.target_ids + declared_inputs = value_inputs(application.spec) + declared_inputs isa NamedTuple || (declared_inputs = NamedTuple()) + for (input_name, selector) in pairs(declared_inputs) + input_sym = Symbol(input_name) + origin = get( + input_origins(application.spec), + input_sym, + :model_spec, + ) + _validate_declared_model_input_name!(application, input_sym) + selector isa AbstractObjectMultiplicity || error( + "Input binding `$(input_sym)` on application `$(application.id)` must use an object selector." + ) + if origin == :model_spec && !(selector isa Many) && + !isnothing(_criteria_get(criteria(selector), :process, nothing)) && + isnothing(_selector_application(selector)) + Base.depwarn( + "`process=` in scenario `Inputs` is deprecated; name the producer application and use `application=`.", + :Inputs, + ) + end + _push_model_input_binding!( + bindings, + model, + application, + consumer_id, + input_sym, + selector, + origin, + by_object, + by_id, + ) + end + application.id in manual_application_ids && continue + _append_inferred_model_input_bindings!( + bindings, + model, + application, + consumer_id, + declared_inputs, + by_object, + by_id, + ) + end + end + return bindings +end + +function _push_model_input_binding!( + bindings, + model::CompositeModel, + application::CompiledModelApplication, + consumer_id::ObjectId, + input_sym::Symbol, + selector::AbstractObjectMultiplicity, + origin::Symbol, + applications_by_object, + applications_by_id, + source_ids_override=nothing, +) + source_ids = isnothing(source_ids_override) ? _dependency_object_ids(model, selector, consumer_id) : source_ids_override + window = _selector_window(selector) + source_var = _selector_var(selector, input_sym) + process_filter = _criteria_get(criteria(selector), :process, nothing) + application_filter = _selector_application(selector) + source_application_ids = _matching_input_source_applications( + applications_by_object, + source_ids, + source_var, + process_filter, + application_filter, + allow_empty=selector isa OptionalOne, + ) + if !(selector isa Many) && + isnothing(process_filter) && + isnothing(application_filter) && + length(source_application_ids) > 1 + source_application_ids = _final_canonical_source_application( + applications_by_object, + source_ids, + source_application_ids, + source_var, + ) + end + if !(selector isa Many) && length(source_application_ids) > 1 + error( + "Input `$(input_sym)` on application `$(application.id)` matched several " * + "source applications `$(source_application_ids)`. Add one of those canonical " * + "identifiers as `application=...` to `Inputs(...)`.", + ) + end + if selector isa OptionalOne && + (!isnothing(process_filter) || !isnothing(application_filter)) && + isempty(source_application_ids) + source_ids = ObjectId[] + end + policy = _model_selector_policy(selector, applications_by_id, source_application_ids, source_var) + if policy isa PreviousTimeStep + policy.variable == input_sym || error( + "PreviousTimeStep marker for input `$(input_sym)` on application ", + "`$(application.id)` names `$(policy.variable)`. Use ", + "`PreviousTimeStep(:$(input_sym))`." + ) + else + _validate_policy_instance( + _model_object(model, consumer_id).scale, + application.process, + input_sym, + policy, + ) + end + carrier = _input_carrier(model, selector, source_ids, source_var) + carrier_hint = + isempty(source_ids) && selector isa OptionalOne ? + :optional_default : + _carrier_hint(selector, policy, window) + _validate_model_input_source!( + model, + application, + consumer_id, + input_sym, + source_var, + source_ids, + carrier, + carrier_hint, + ) + push!( + bindings, + CompiledModelInputBinding( + application.id, + consumer_id, + input_sym, + selector, + origin, + source_ids, + source_application_ids, + source_var, + process_filter, + application_filter, + multiplicity(selector), + policy, + window, + carrier_hint, + carrier, + ), + ) + return bindings +end + +function _model_input_names(application::CompiledModelApplication) + return Symbol[Symbol(var) for var in keys(inputs_(application.spec))] +end + +function _validate_model_input_source!( + model::CompositeModel, + application::CompiledModelApplication, + consumer_id::ObjectId, + input_sym::Symbol, + source_var::Symbol, + source_ids::Vector{ObjectId}, + carrier, + carrier_hint::Symbol, +) + carrier_hint == :temporal_stream && return nothing + !isnothing(carrier) && return nothing + status_source_ids = ObjectId[ + source_id for source_id in source_ids + if _model_object(model, source_id).status isa Status + ] + isempty(status_source_ids) && return nothing + error( + "Input binding `$(input_sym)` on application `$(application.id)` for object ", + "`$(consumer_id.value)` reads `$(source_var)` from objects ", + "`$([id.value for id in status_source_ids])`, but no source `Status` reference is available." + ) +end + +function _validate_declared_model_input_name!(application::CompiledModelApplication, input_sym::Symbol) + input_names = Set(_model_input_names(application)) + input_sym in input_names && return nothing + error( + "Input binding `$(input_sym)` on application `$(application.id)` is not declared by ", + "`inputs_` for process `$(application.process)`. Declared model inputs are ", + "`$(sort!(collect(input_names)))`." + ) +end + +function _same_object_output_applications(applications_by_object, application::CompiledModelApplication, object_id::ObjectId, variable::Symbol) + matches = CompiledModelApplication[] + for candidate in get(applications_by_object, object_id, Any[]) + candidate.id == application.id && continue + variable in _model_canonical_output_names(candidate) || continue + push!(matches, candidate) + end + return matches +end + +function _append_inferred_model_input_bindings!( + bindings, + model::CompositeModel, + application::CompiledModelApplication, + consumer_id::ObjectId, + declared_inputs, + applications_by_object, + applications_by_id, +) + declared_names = declared_inputs isa NamedTuple ? Set(Symbol.(keys(declared_inputs))) : Set{Symbol}() + for input_sym in _model_input_names(application) + input_sym in declared_names && continue + matches = _same_object_output_applications(applications_by_object, application, consumer_id, input_sym) + isempty(matches) && continue + if length(matches) > 1 + error( + "Input `$(input_sym)` on application `$(application.id)` for object `$(consumer_id.value)` ", + "has ambiguous same-object producers: `$([match.id for match in matches])`. ", + "Add `Inputs(:$(input_sym) => One(...))` to disambiguate." + ) + end + producer = only(matches) + selector = One(within=Self(), process=producer.process, application=producer.id, var=input_sym) + _push_model_input_binding!( + bindings, + model, + application, + consumer_id, + input_sym, + selector, + :inferred_same_object, + applications_by_object, + applications_by_id, + ObjectId[consumer_id], + ) + end + return bindings +end + +function _bound_model_inputs(input_bindings) + bound = Set{Tuple{Symbol,ObjectId,Symbol}}() + for binding in input_bindings + push!(bound, (binding.application_id, binding.consumer_id, binding.input)) + end + return bound +end + +function _status_has_variable(model::CompositeModel, object_id::ObjectId, variable::Symbol) + object = _model_object(model, object_id) + object.status isa Status || return false + return variable in propertynames(object.status) +end + +function _validate_model_required_inputs!(model::CompositeModel, applications, input_bindings) + bound = _bound_model_inputs(input_bindings) + missing = NamedTuple[] + for application in applications + for object_id in application.target_ids + for input in _model_input_names(application) + (application.id, object_id, input) in bound && continue + _status_has_variable(model, object_id, input) && continue + push!( + missing, + ( + application_id=application.id, + object_id=object_id.value, + input=input, + process=application.process, + ), + ) + end + end + end + isempty(missing) && return nothing + details = join( + [ + "`$(row.application_id)` on object `$(row.object_id)` requires `$(row.input)`" + for row in missing + ], + "; ", + ) + error( + "Missing required composite-model/object input(s): ", + details, + ". Provide the variable on object `Status`, add an `Inputs(...)` binding, ", + "or add an unambiguous same-object producer." + ) +end + +function _applications_by_object(applications) + by_object = Dict{ObjectId,Vector{Any}}() + for application in applications + for object_id in application.target_ids + push!(get!(by_object, object_id, Any[]), application) + end + end + return by_object +end + +function _matching_callee_applications(applications, object_id::ObjectId, proc, application_name_filter) + matches = Symbol[] + for application in get(applications, object_id, Any[]) + isnothing(proc) || application.process == proc || continue + isnothing(application_name_filter) || application.id == application_name_filter || continue + push!(matches, application.id) + end + return matches +end + +function _compile_model_call_bindings( + model::CompositeModel, + applications, + lookup_applications=applications, + ; + by_object=nothing, +) + isnothing(by_object) && (by_object = _applications_by_object(lookup_applications)) + bindings = CompiledModelCallBinding[] + for application in applications + calls = model_calls(application.spec) + calls isa NamedTuple || continue + for consumer_id in application.target_ids + for (call_name, selector) in pairs(calls) + call_sym = Symbol(call_name) + origin = get( + call_origins(application.spec), + call_sym, + :model_spec, + ) + selector isa AbstractObjectMultiplicity || error( + "Call binding `$(call_sym)` on application `$(application.id)` must use an object selector." + ) + if origin == :model_spec && !(selector isa Many) && + !isnothing(_criteria_get(criteria(selector), :process, nothing)) && + isnothing(_selector_application(selector)) + Base.depwarn( + "`process=` in scenario `Calls` is deprecated; name the callee application and use `application=`.", + :Calls, + ) + end + callee_object_ids = _dependency_object_ids(model, selector, consumer_id) + proc = _criteria_get(criteria(selector), :process, nothing) + app_name = _selector_application(selector) + callee_application_ids = Symbol[] + for object_id in callee_object_ids + append!( + callee_application_ids, + _matching_callee_applications(by_object, object_id, proc, app_name), + ) + end + unique!(callee_application_ids) + if isempty(callee_application_ids) && selector isa One + error( + "Call `$(call_sym)` on application `$(application.id)` matched objects ", + "$([id.value for id in callee_object_ids]) but no model application", + isnothing(proc) ? "." : " with process `$(proc)`.", + ) + end + if selector isa One && length(callee_application_ids) != 1 + error( + "Call `$(call_sym)` on application `$(application.id)` expected one callee application, ", + "got `$(callee_application_ids)`. Add `application=:name` to disambiguate." + ) + elseif selector isa OptionalOne && length(callee_application_ids) > 1 + error( + "Call `$(call_sym)` on application `$(application.id)` expected zero or one callee application, ", + "got `$(callee_application_ids)`. Add `application=:name` to disambiguate." + ) + end + push!( + bindings, + CompiledModelCallBinding( + application.id, + consumer_id, + call_sym, + selector, + origin, + callee_object_ids, + callee_application_ids, + proc, + app_name, + multiplicity(selector), + ), + ) + end + end + end + return bindings +end + +function _model_call_owners(call_bindings) + owners = Dict{Symbol,Set{Symbol}}() + for binding in call_bindings + for callee_id in binding.callee_application_ids + push!(get!(owners, callee_id, Set{Symbol}()), binding.application_id) + end + end + return owners +end + +function _add_model_application_edge!(children, parent::Symbol, child::Symbol) + parent == child && return nothing + push!(get!(children, parent, Set{Symbol}()), child) + return nothing +end + +function _model_input_order_edges!(children, input_bindings, call_owners) + for binding in input_bindings + binding.policy isa PreviousTimeStep && continue + for source_id in binding.source_application_ids + owners = get(call_owners, source_id, nothing) + if isnothing(owners) + _add_model_application_edge!(children, source_id, binding.application_id) + else + for owner_id in owners + _add_model_application_edge!(children, owner_id, binding.application_id) + end + end + end + end + return children +end + +function _model_update_order_edges!(children, applications) + for indexed_writers in values(_model_writer_groups(applications)) + length(indexed_writers) > 1 || continue + sort!(indexed_writers; by=first) + for index in 2:length(indexed_writers) + previous_application = indexed_writers[index - 1][2] + application = indexed_writers[index][2] + _add_model_application_edge!(children, previous_application.id, application.id) + end + end + return children +end + +function _stable_topological_application_order(applications, children) + application_ids = Symbol[application.id for application in applications] + positions = Dict(application_id => index for (index, application_id) in pairs(application_ids)) + indegree = Dict(application_id => 0 for application_id in application_ids) + for child_ids in values(children) + for child_id in child_ids + indegree[child_id] = get(indegree, child_id, 0) + 1 + end + end + ready = Symbol[application_id for application_id in application_ids if indegree[application_id] == 0] + order = Symbol[] + while !isempty(ready) + sort!(ready; by=application_id -> positions[application_id]) + application_id = popfirst!(ready) + push!(order, application_id) + child_ids = sort!(collect(get(children, application_id, Set{Symbol}())); by=child_id -> positions[child_id]) + for child_id in child_ids + indegree[child_id] -= 1 + indegree[child_id] == 0 && push!(ready, child_id) + end + end + if length(order) != length(application_ids) + remaining = Symbol[application_id for application_id in application_ids if indegree[application_id] > 0] + error( + "Composite model application dependency cycle detected among applications `$(remaining)`. ", + "Break the same-timestep cycle with a temporal policy or revise `Inputs(...)`/`Updates(...)`." + ) + end + return order +end + +function _compile_model_application_order(applications, input_bindings, call_bindings) + children = Dict{Symbol,Set{Symbol}}() + call_owners = _model_call_owners(call_bindings) + _model_input_order_edges!(children, input_bindings, call_owners) + _model_update_order_edges!(children, applications) + return _stable_topological_application_order(applications, children) +end + +function _ordered_model_applications(compiled::CompiledCompositeModel) + return [compiled.applications_by_id[application_id] for application_id in compiled.application_order] +end + +function explain_applications(compiled::CompiledCompositeModel) + return [ + ( + application_id=application.id, + process=application.process, + name=application.name, + target_ids=[id.value for id in application.target_ids], + target_scales=sort!(unique!(Symbol[ + _model_object(compiled.model, id).scale + for id in application.target_ids + if !isnothing(_model_object(compiled.model, id).scale) + ]); by=string), + applies_to=application.applies_to, + inputs=Tuple(Symbol.(keys(inputs_(application.spec)))), + outputs=Tuple(Symbol.(keys(outputs_(application.spec)))), + environment_inputs=Tuple(Symbol.(keys(meteo_inputs_(application.spec)))), + environment_outputs=Tuple(Symbol.(keys(meteo_outputs_(application.spec)))), + timestep=application.timestep, + clock=application.clock, + model_type=typeof(_application_default_model(application)), + model_storage=isnothing(application.model_overrides) ? :shared_application : :per_object_override, + model_dispatch=_application_model_dispatch(application), + object_overrides=isnothing(application.model_overrides) ? + NamedTuple[] : + [ + ( + object_id=object_id.value, + model_type=typeof(model), + ) + for (object_id, model) in sort!( + collect(application.model_overrides); + by=pair -> string(first(pair).value), + ) + ], + ) + for application in compiled.applications + ] +end + +explain_applications(model::CompositeModel) = + explain_applications(refresh_bindings!(model)) + +function _application_model_dispatch(application::CompiledModelApplication) + isnothing(application.model_overrides) && return :concrete_shared + override_type = valtype(typeof(application.model_overrides)) + default_type = typeof(_application_default_model(application)) + return isconcretetype(override_type) && default_type == override_type ? + :concrete_per_object : + :heterogeneous_per_object +end + +function explain_schedule(compiled::CompiledCompositeModel) + timeline = compiled.timeline + manual_application_ids = _manual_call_application_ids(compiled) + execution_positions = Dict(application_id => index for (index, application_id) in pairs(compiled.application_order)) + return [ + ( + application_id=application.id, + process=application.process, + execution_index=execution_positions[application.id], + timestep=application.timestep, + clock=application.clock, + dt_steps=application.clock.dt, + phase=application.clock.phase, + dt_seconds=float(application.clock.dt) * timeline.base_step_seconds, + target_ids=[id.value for id in application.target_ids], + root_scheduled=!(application.id in manual_application_ids), + manual_call_only=application.id in manual_application_ids, + ) + for application in _ordered_model_applications(compiled) + ] +end + +explain_schedule(model::CompositeModel) = explain_schedule(refresh_bindings!(model)) + +function _model_binding_carrier_kind(binding::CompiledModelInputBinding) + binding.carrier_hint == :temporal_stream && return :temporal_stream + binding.carrier_hint == :optional_default && return :optional_default + carrier = binding.carrier + isnothing(carrier) && return :unresolved + carrier isa Base.RefValue && return :ref + carrier isa RefVector && return :ref_vector + carrier isa ObjectRefVector && return :object_ref_vector + return :custom +end + +function _model_binding_copy_semantics(binding::CompiledModelInputBinding) + kind = _model_binding_carrier_kind(binding) + kind in (:ref, :ref_vector, :object_ref_vector) && return :live_references + kind == :temporal_stream && return :materialized_temporal_value + kind == :optional_default && return :consumer_default + kind == :unresolved && return :not_materialized + return :backend_defined +end + +function explain_bindings(compiled::CompiledCompositeModel) + return [ + ( + application_id=binding.application_id, + consumer_id=binding.consumer_id.value, + input=binding.input, + origin=binding.origin, + source_ids=[id.value for id in binding.source_ids], + source_application_ids=binding.source_application_ids, + source_var=binding.source_var, + process=binding.process, + application=binding.application, + multiplicity=binding.multiplicity, + policy=binding.policy, + window=binding.window, + carrier_hint=binding.carrier_hint, + carrier_kind=_model_binding_carrier_kind(binding), + copy_semantics=_model_binding_copy_semantics(binding), + has_reference_carrier=has_reference_carrier(binding), + carrier_type=isnothing(binding.carrier) ? nothing : typeof(binding.carrier), + selector=binding.selector, + ) + for binding in compiled.input_bindings + ] +end + +explain_bindings(model::CompositeModel) = explain_bindings(refresh_bindings!(model)) + +function explain_calls(compiled::CompiledCompositeModel) + return [ + ( + application_id=binding.application_id, + consumer_id=binding.consumer_id.value, + call=binding.call, + origin=binding.origin, + callee_object_ids=[id.value for id in binding.callee_object_ids], + callee_application_ids=binding.callee_application_ids, + process=binding.process, + application=binding.application, + multiplicity=binding.multiplicity, + publication_policy=:explicit_accept, + default_publish=false, + accepted_publish=true, + resolved=!isempty(binding.callee_application_ids), + selector=binding.selector, + ) + for binding in compiled.call_bindings + ] +end + +explain_calls(model::CompositeModel) = explain_calls(refresh_bindings!(model)) + +function explain_model_bundles(compiled::CompiledCompositeModel) + return [ + ( + application_id=application_id, + object_id=object_id.value, + processes=collect(keys(models)), + model_types=[typeof(model) for model in values(models)], + ) + for ((application_id, object_id), models) in sort!( + collect(compiled.model_bundles_by_target); + by=pair -> (string(first(pair)[1]), string(first(pair)[2].value)), + ) + ] +end + +explain_model_bundles(model::CompositeModel) = + explain_model_bundles(refresh_bindings!(model)) + +function explain_writers(compiled::CompiledCompositeModel) + groups = _model_writer_groups(compiled.applications, _manual_call_application_ids(compiled)) + rows = NamedTuple[] + for ((object_id, variable), indexed_writers) in groups + sort!(indexed_writers; by=first) + applications = [application for (_, application) in indexed_writers] + push!( + rows, + ( + object_id=object_id.value, + variable=variable, + application_ids=[application.id for application in applications], + processes=[application.process for application in applications], + update_application_ids=[ + application.id for application in applications + if !isempty(_matching_updates(application.spec, variable)) + ], + update_after=[ + application.id => _update_after_labels(application.spec, variable) + for application in applications + if !isempty(_matching_updates(application.spec, variable)) + ], + duplicate=length(applications) > 1, + ), + ) + end + sort!(rows; by=row -> (string(row.object_id), string(row.variable))) + return rows +end + +explain_writers(model::CompositeModel) = explain_writers(refresh_bindings!(model)) diff --git a/src/composite_model/environment_bindings.jl b/src/composite_model/environment_bindings.jl new file mode 100644 index 000000000..32a397eea --- /dev/null +++ b/src/composite_model/environment_bindings.jl @@ -0,0 +1,440 @@ +function _environment_config_payload(config) + config isa EnvironmentConfig && return config.config + return config +end + +function _environment_backend_from_config(model::CompositeModel, config) + payload = _environment_config_payload(config) + isnothing(payload) && return environment_backend(model.environment) + payload isa NamedTuple && haskey(payload, :backend) && return environment_backend(payload.backend) + payload isa AbstractEnvironmentBackend && return payload + return environment_backend(model.environment) +end + +function _environment_provider_from_config(config, backend) + payload = _environment_config_payload(config) + payload isa NamedTuple && haskey(payload, :provider) && return Symbol(payload.provider) + payload isa Symbol && return payload + isnothing(backend) && return :none + return :model +end + +function _object_environment_support(application::CompiledModelApplication, object::Object) + scale = isnothing(object.scale) ? :Default : object.scale + return EnvironmentSupport(application.id, scale, application.process, object.status) +end + +bind_environment(backend, object::Object, support, config=nothing) = :global + +function _environment_binding_object(model::CompositeModel, object::Object) + !isnothing(geometry(object)) && return object, object.id, :self + ancestor_id = object.parent + while !isnothing(ancestor_id) + ancestor = _model_object(model, ancestor_id) + if !isnothing(geometry(ancestor)) + proxy = Object( + object.id; + scale=object.scale, + kind=object.kind, + species=object.species, + name=object.name, + parent=object.parent, + children=object.children, + geometry=geometry(ancestor), + status=object.status, + applications=object.applications, + ) + return proxy, ancestor.id, :ancestor + end + ancestor_id = ancestor.parent + end + return object, nothing, :global +end + +function _model_environment_entities(model::CompositeModel) + return [ + ( + id=object.id.value, + object=object, + scale=object.scale, + kind=object.kind, + species=object.species, + name=object.name, + parent=isnothing(object.parent) ? nothing : object.parent.value, + geometry=geometry(object), + position=position(object), + bounds=bounds(object), + status=object.status, + ) + for object in model_objects(model) + ] +end + +function _model_environment_backends(model::CompositeModel, compiled::CompiledCompositeModel) + backends = Any[] + seen = Set{UInt}() + for application in compiled.applications + backend = _environment_backend_from_config(model, environment_config(application.spec)) + isnothing(backend) && continue + id = objectid(backend) + id in seen && continue + push!(seen, id) + push!(backends, backend) + end + return backends +end + +function _update_model_environment_indices!(model::CompositeModel, compiled::CompiledCompositeModel) + entities = _model_environment_entities(model) + for backend in _model_environment_backends(model, compiled) + update_index!(backend, entities) + end + return nothing +end + +function _environment_variable_names(vars) + return Symbol[Symbol(var) for var in keys(vars)] +end + +function _environment_source_variable_names(model_spec) + return Symbol[Symbol(source) for (_, source) in _environment_sampling_rules(model_spec)] +end + +function _compile_environment_bindings_for_applications(model::CompositeModel, applications) + bindings = CompiledEnvironmentBinding[] + for application in applications + config = environment_config(application.spec) + backend = _environment_backend_from_config(model, config) + provider = _environment_provider_from_config(config, backend) + required_inputs = _environment_variable_names(meteo_inputs_(application.spec)) + source_inputs = _environment_source_variable_names(application.spec) + produced_outputs = _environment_variable_names(meteo_outputs_(application.spec)) + for object_id in application.target_ids + object = _model_object(model, object_id) + support = _object_environment_support(application, object) + binding_object, geometry_source_object_id, geometry_source = + _environment_binding_object(model, object) + cell = bind_environment( + backend, + binding_object, + support, + _environment_config_payload(config), + ) + push!( + bindings, + CompiledEnvironmentBinding( + application.id, + object_id, + provider, + backend, + cell, + required_inputs, + source_inputs, + produced_outputs, + support, + geometry_source_object_id, + geometry_source, + config, + ), + ) + end + end + return bindings +end + +_compile_environment_bindings(model::CompositeModel, compiled::CompiledCompositeModel) = + _compile_environment_bindings_for_applications(model, compiled.applications) + +function _validate_model_environment_inputs!(bindings, applications_by_id) + missing_rows = NamedTuple[] + for binding in bindings + available = environment_variables(binding.backend) + isnothing(available) && continue + application = applications_by_id[binding.application_id] + for (target, source) in _environment_sampling_rules(application.spec) + source in available && continue + push!( + missing_rows, + ( + application_id=binding.application_id, + object_id=binding.object_id.value, + process=application.process, + target=target, + source=source, + available=Tuple(sort!(collect(available); by=string)), + ), + ) + end + end + isempty(missing_rows) && return nothing + details = join( + [ + string( + row.application_id, + "/", + row.object_id, + " (", + row.process, + ") needs `", + row.target, + "` from source `", + row.source, + "`; available=", + row.available, + ) + for row in missing_rows + ], + "; ", + ) + error("Composite model environment is missing required meteo inputs: ", details) +end + +function _model_environment_samplers(bindings) + samplers_by_application = Dict{Symbol,Any}() + prepared_sources = Tuple{Any,Any}[] + for binding in bindings + haskey(samplers_by_application, binding.application_id) && continue + binding.backend isa GlobalConstant || continue + meteo = environment_meteo(binding.backend) + source_index = findfirst(entry -> entry[1] === meteo, prepared_sources) + sampler = if isnothing(source_index) + prepared = _prepare_meteo_sampler(meteo) + push!(prepared_sources, (meteo, prepared)) + prepared + else + prepared_sources[source_index][2] + end + samplers_by_application[binding.application_id] = sampler + end + return samplers_by_application +end + +function _compiled_environment_bindings( + model::CompositeModel, + bindings, + by_target, + samplers_by_application=_model_environment_samplers(bindings), + sample_cache=Dict{Tuple{Symbol,Int},Any}(), +) + return CompiledEnvironmentBindings( + model, + bindings, + by_target, + samplers_by_application, + sample_cache, + model.revision, + model.environment_revision, + ) +end + +function compile_environment_bindings(model::CompositeModel, compiled::CompiledCompositeModel=refresh_bindings!(model)) + _update_model_environment_indices!(model, compiled) + bindings = _compile_environment_bindings(model, compiled) + _validate_model_environment_inputs!(bindings, compiled.applications_by_id) + by_target = Dict( + (binding.application_id, binding.object_id) => binding + for binding in bindings + ) + length(by_target) == length(bindings) || error( + "Environment binding compilation produced duplicate `(application_id, object_id)` targets." + ) + return _compiled_environment_bindings(model, bindings, by_target) +end + +function _same_environment_backend(a, b) + a === b && return true + if a isa GlobalConstant && b isa GlobalConstant + return environment_meteo(a) === environment_meteo(b) + end + return false +end + +function _same_environment_support(a, b) + return a.application == b.application && + a.scale == b.scale && + a.process == b.process && + a.status === b.status +end + +function _reconcile_environment_binding_metadata( + model::CompositeModel, + compiled::CompiledCompositeModel, + cached::CompiledEnvironmentBindings, +) + expected_count = sum(length(application.target_ids) for application in compiled.applications) + expected_count == length(cached.bindings) || return nothing + + bindings = CompiledEnvironmentBinding[] + changed = false + for application in compiled.applications + config = environment_config(application.spec) + backend = _environment_backend_from_config(model, config) + provider = _environment_provider_from_config(config, backend) + required_inputs = _environment_variable_names(meteo_inputs_(application.spec)) + source_inputs = _environment_source_variable_names(application.spec) + produced_outputs = _environment_variable_names(meteo_outputs_(application.spec)) + for object_id in application.target_ids + key = (application.id, object_id) + old = get(cached.by_target, key, nothing) + isnothing(old) && return nothing + object = _model_object(model, object_id) + support = _object_environment_support(application, object) + _, geometry_source_object_id, geometry_source = + _environment_binding_object(model, object) + _same_environment_backend(old.backend, backend) || return nothing + old.provider == provider || return nothing + isequal(old.config, config) || return nothing + old.geometry_source_object_id == geometry_source_object_id || + return nothing + old.geometry_source == geometry_source || return nothing + _same_environment_support(old.support, support) || return nothing + + if old.required_inputs == required_inputs && + old.source_inputs == source_inputs && + old.produced_outputs == produced_outputs + push!(bindings, old) + else + changed = true + push!( + bindings, + CompiledEnvironmentBinding( + application.id, + object_id, + provider, + backend, + old.cell, + required_inputs, + source_inputs, + produced_outputs, + support, + geometry_source_object_id, + geometry_source, + config, + ), + ) + end + end + end + changed || return cached + _validate_model_environment_inputs!(bindings, compiled.applications_by_id) + by_target = Dict( + (binding.application_id, binding.object_id) => binding + for binding in bindings + ) + return _compiled_environment_bindings(model, bindings, by_target) +end + +function _refresh_environment_bindings_for_objects( + model::CompositeModel, + compiled::CompiledCompositeModel, + cached::CompiledEnvironmentBindings, + dirty_object_ids, +) + _update_model_environment_indices!(model, compiled) + dirty = Set(dirty_object_ids) + for application in compiled.applications + selected_ids = ObjectId[id for id in application.target_ids if id in dirty] + isempty(selected_ids) && continue + has_new_target = any( + object_id -> !haskey(cached.by_target, (application.id, object_id)), + selected_ids, + ) + has_new_target || continue + config = environment_config(application.spec) + backend = _environment_backend_from_config(model, config) + backend isa GlobalConstant && continue + bindings = _compile_environment_bindings(model, compiled) + _validate_model_environment_inputs!(bindings, compiled.applications_by_id) + by_target = Dict( + (binding.application_id, binding.object_id) => binding + for binding in bindings + ) + return _compiled_environment_bindings(model, bindings, by_target) + end + replacements = Dict{Tuple{Symbol,ObjectId},CompiledEnvironmentBinding}() + for application in compiled.applications + selected_ids = ObjectId[id for id in application.target_ids if id in dirty] + isempty(selected_ids) && continue + partial_application = CompiledModelApplication( + application.id, + application.spec, + application.process, + application.name, + selected_ids, + application.applies_to, + application.timestep, + application.clock, + application.model_overrides, + ) + for binding in _compile_environment_bindings_for_applications(model, (partial_application,)) + replacements[(binding.application_id, binding.object_id)] = binding + end + end + added_targets = Tuple{Symbol,ObjectId}[ + target for target in keys(replacements) + if !haskey(cached.by_target, target) + ] + if length(added_targets) == length(replacements) + append!(cached.bindings, values(replacements)) + merge!(cached.by_target, replacements) + _validate_model_environment_inputs!(values(replacements), compiled.applications_by_id) + return _compiled_environment_bindings( + model, + cached.bindings, + cached.by_target, + cached.samplers_by_application, + cached.sample_cache, + ) + end + bindings = CompiledEnvironmentBinding[ + get(replacements, (binding.application_id, binding.object_id), binding) + for binding in cached.bindings + ] + cached_targets = Set(keys(cached.by_target)) + append!( + bindings, + ( + binding for (target, binding) in replacements + if !(target in cached_targets) + ), + ) + _validate_model_environment_inputs!(bindings, compiled.applications_by_id) + by_target = Dict( + (binding.application_id, binding.object_id) => binding for binding in bindings + ) + return _compiled_environment_bindings( + model, + bindings, + by_target, + cached.samplers_by_application, + cached.sample_cache, + ) +end + +function explain_environment_bindings(compiled::CompiledEnvironmentBindings) + return [ + ( + application_id=binding.application_id, + object_id=binding.object_id.value, + provider=binding.provider, + backend_type=isnothing(binding.backend) ? nothing : typeof(binding.backend), + cell=binding.cell, + required_inputs=binding.required_inputs, + source_inputs=binding.source_inputs, + produced_outputs=binding.produced_outputs, + temporal_sampler=!isnothing( + get(compiled.samplers_by_application, binding.application_id, nothing), + ), + geometry_source_object_id=isnothing(binding.geometry_source_object_id) ? + nothing : binding.geometry_source_object_id.value, + geometry_source=binding.geometry_source, + support=binding.support, + config=binding.config, + ) + for binding in compiled.bindings + ] +end + +function explain_environment_bindings(model::CompositeModel) + return explain_environment_bindings(refresh_environment_bindings!(model)) +end diff --git a/src/composite_model/registry_topology.jl b/src/composite_model/registry_topology.jl new file mode 100644 index 000000000..aade14ee8 --- /dev/null +++ b/src/composite_model/registry_topology.jl @@ -0,0 +1,1185 @@ +abstract type AbstractObjectSelector end +abstract type AbstractObjectMultiplicity end + +struct ObjectRefVector{R} <: AbstractVector{Any} + refs::R +end + +Base.size(v::ObjectRefVector) = size(v.refs) +Base.length(v::ObjectRefVector) = length(v.refs) +Base.getindex(v::ObjectRefVector, i::Int) = v.refs[i][] +Base.setindex!(v::ObjectRefVector, value, i::Int) = (v.refs[i][] = value) +Base.parent(v::ObjectRefVector) = v.refs + +struct ObjectId{T} + value::T +end +ObjectId(id::ObjectId) = id +ObjectId(id::AbstractString) = ObjectId(Symbol(id)) + +mutable struct Object + id::ObjectId + scale::Union{Nothing,Symbol} + kind::Union{Nothing,Symbol} + species::Union{Nothing,Symbol} + name::Union{Nothing,Symbol} + parent::Union{Nothing,ObjectId} + children::Vector{ObjectId} + geometry::Any + status::Any + applications::Any +end + +""" + CompositeModelTemplate(applications=(); kind=nothing, species=nothing, parameters=NamedTuple()) + +Reusable model-application bundle for one kind of model object, such as a plant +species. Each mounted `ObjectInstance` scopes unqualified `AppliesTo(...)` +selectors to its own object subtree. Model objects are shared between instances +unless an instance supplies an override. + +`parameters` stores template-level metadata. Parameter-field merging is not +implicit: use an instance model override when model parameters differ. +""" +struct CompositeModelTemplate{A,P} + kind::Union{Nothing,Symbol} + species::Union{Nothing,Symbol} + applications::A + parameters::P +end + +_as_tuple(value::Tuple) = value +_as_tuple(value::AbstractVector) = Tuple(value) +_as_tuple(value) = (value,) + +function CompositeModelTemplate( + applications=(); + kind=nothing, + species=nothing, + parameters=NamedTuple(), +) + normalized_applications = _as_tuple(applications) + return CompositeModelTemplate( + _maybe_symbol(kind), + _maybe_symbol(species), + normalized_applications, + parameters, + ) +end + +""" + Override(; object, model, process=nothing, application=nothing) + +Replace one template model application on one exceptional object. Select the +template application by its process, explicit application name, or both. +The replacement must implement the same process and variable contract. +""" +struct Override{M<:AbstractModel} + object::ObjectId + process::Union{Nothing,Symbol} + application::Union{Nothing,Symbol} + model::M +end + +function Override(; object, model::AbstractModel, process=nothing, application=nothing) + normalized_process = _maybe_symbol(process) + normalized_application = _maybe_symbol(application) + if isnothing(normalized_process) && isnothing(normalized_application) + error("`Override(...)` requires `process=...`, `application=...`, or both.") + end + if !isnothing(normalized_process) && isnothing(normalized_application) + Base.depwarn( + "Process-only `Override` selection is deprecated; name the template application and use `application=`.", + :Override, + ) + end + return Override( + ObjectId(object), + normalized_process, + normalized_application, + model, + ) +end + +""" + ObjectInstance(name, template; root, objects=(), overrides=NamedTuple(), object_overrides=()) + +Mount a `CompositeModelTemplate` on one concrete object subtree. + +`root` may be an `Object` owned by the instance or the id of an object supplied +separately to `CompositeModel`. `objects` contains additional owned descendants. +`overrides` maps one template application name or process to a replacement +model implementing the same process. `object_overrides` contains `Override` +entries for exceptional organs. +""" +struct ObjectInstance{T,R,O,OV,OOV} + name::Symbol + template::T + root::R + objects::O + overrides::OV + object_overrides::OOV +end + +function ObjectInstance( + name, + template::CompositeModelTemplate; + root, + objects=(), + overrides=NamedTuple(), + object_overrides=(), +) + normalized_objects = _as_tuple(objects) + normalized_object_overrides = _as_tuple(object_overrides) + all(object -> object isa Object, normalized_objects) || error( + "`ObjectInstance(...; objects=...)` must contain `Object` values." + ) + overrides isa NamedTuple || error( + "`ObjectInstance(...; overrides=...)` must be a NamedTuple keyed by application name or process." + ) + all(override -> override isa Override, normalized_object_overrides) || error( + "`ObjectInstance(...; object_overrides=...)` must contain `Override` values." + ) + return ObjectInstance( + Symbol(name), + template, + root, + normalized_objects, + overrides, + normalized_object_overrides, + ) +end + +struct ObjectModelOverrides{M,O} <: AbstractModel + base::M + overrides::O +end + +process(model::ObjectModelOverrides) = process(model.base) +inputs_(model::ObjectModelOverrides) = inputs_(model.base) +outputs_(model::ObjectModelOverrides) = outputs_(model.base) +dep(model::ObjectModelOverrides) = dep(model.base) +timespec(model::ObjectModelOverrides) = timespec(model.base) +output_policy(model::ObjectModelOverrides) = output_policy(model.base) +timestep_hint(model::ObjectModelOverrides) = timestep_hint(model.base) +meteo_hint(model::ObjectModelOverrides) = meteo_hint(model.base) +meteo_inputs_(model::ObjectModelOverrides) = meteo_inputs_(model.base) +meteo_outputs_(model::ObjectModelOverrides) = meteo_outputs_(model.base) + +function Object( + id; + scale=nothing, + kind=nothing, + species=nothing, + name=nothing, + parent=nothing, + children=ObjectId[], + geometry=nothing, + status=nothing, + applications=(), +) + return Object( + ObjectId(id), + _maybe_symbol(scale), + _maybe_symbol(kind), + _maybe_symbol(species), + _maybe_symbol(name), + isnothing(parent) ? nothing : ObjectId(parent), + ObjectId[ObjectId(child) for child in children], + geometry, + status, + applications, + ) +end + +mutable struct ObjectRegistry + objects::Dict{ObjectId,Any} + by_scale::Dict{Symbol,Set{ObjectId}} + by_kind::Dict{Symbol,Set{ObjectId}} + by_species::Dict{Symbol,Set{ObjectId}} + by_name::Dict{Symbol,ObjectId} +end + +ObjectRegistry() = ObjectRegistry( + Dict{ObjectId,Any}(), + Dict{Symbol,Set{ObjectId}}(), + Dict{Symbol,Set{ObjectId}}(), + Dict{Symbol,Set{ObjectId}}(), + Dict{Symbol,ObjectId}(), +) + +struct MTGObjectAdapter{I,S,K,SP,N,G,ST} + id::I + scale::S + kind::K + species::SP + name::N + geometry::G + status::ST + max_node_id::Base.RefValue{Int} +end + +mutable struct CompositeModel{R,A,E,I,SA} + registry::R + applications::A + environment::E + instances::I + source_adapter::SA + binding_cache::Any + environment_binding_cache::Any + bindings_dirty::Bool + environment_bindings_dirty::Bool + environment_dirty_objects::Union{Nothing,Set{ObjectId}} + binding_dirty_objects::Union{Nothing,Set{ObjectId}} + revision::Int + environment_revision::Int +end + +function _normalize_object_instances(instances) + instances isa ObjectInstance && return (instances,) + normalized = _as_tuple(instances) + all(instance -> instance isa ObjectInstance, normalized) || error( + "CompositeModel instances must be `ObjectInstance` values." + ) + return normalized +end + +function _instance_root_id(instance::ObjectInstance) + return instance.root isa Object ? instance.root.id : ObjectId(instance.root) +end + +function _collect_model_items(items, instances) + objects = Object[] + mounted_instances = ObjectInstance[] + for item in items + if item isa Object + push!(objects, item) + elseif item isa ObjectInstance + push!(mounted_instances, item) + else + error("A `CompositeModel` can contain only `Object` and `ObjectInstance` values, got `$(typeof(item))`.") + end + end + append!(mounted_instances, _normalize_object_instances(instances)) + for instance in mounted_instances + instance.root isa Object && push!(objects, instance.root) + append!(objects, instance.objects) + end + ids = Set{ObjectId}() + for object in objects + object.id in ids && error("CompositeModel contains object id `$(object.id.value)` more than once.") + push!(ids, object.id) + end + return objects, mounted_instances +end + +function _object_descendant_ids(objects_by_id, root_id::ObjectId) + ids = ObjectId[root_id] + frontier = ObjectId[root_id] + while !isempty(frontier) + parent_id = popfirst!(frontier) + for object in values(objects_by_id) + object.parent == parent_id || continue + object.id in ids && continue + push!(ids, object.id) + push!(frontier, object.id) + end + end + return ids +end + +function _prepare_object_instances!(objects, instances) + objects_by_id = Dict(object.id => object for object in objects) + claimed_ids = Dict{ObjectId,Symbol}() + instance_ids = Dict{Symbol,Vector{ObjectId}}() + for instance in instances + root_id = _instance_root_id(instance) + haskey(objects_by_id, root_id) || error( + "Object instance `$(instance.name)` refers to missing root object `$(root_id.value)`." + ) + ids = _object_descendant_ids(objects_by_id, root_id) + for id in ids + if haskey(claimed_ids, id) + error( + "Object `$(id.value)` belongs to both instances `$(claimed_ids[id])` and `$(instance.name)`." + ) + end + claimed_ids[id] = instance.name + object = objects_by_id[id] + isnothing(object.kind) && (object.kind = instance.template.kind) + isnothing(object.species) && (object.species = instance.template.species) + end + root = objects_by_id[root_id] + if !isnothing(root.name) && root.name != instance.name + error( + "Object instance `$(instance.name)` root `$(root_id.value)` already has the conflicting name `$(root.name)`." + ) + end + root.name = instance.name + instance_ids[instance.name] = ids + end + length(instance_ids) == length(instances) || error("Object instance names must be unique within a model.") + return instance_ids +end + +function _register_model_objects!(model::CompositeModel, objects) + pending = copy(objects) + while !isempty(pending) + registered = false + for index in reverse(eachindex(pending)) + object = pending[index] + if isnothing(object.parent) || haskey(model.registry.objects, object.parent) + register_object!(model, object) + deleteat!(pending, index) + registered = true + end + end + registered && continue + unresolved = [(object.id.value, isnothing(object.parent) ? nothing : object.parent.value) for object in pending] + error("Cannot register model objects because parent objects are missing or cyclic: $(unresolved).") + end + return model +end + +""" + CompositeModel(items...; applications=(), instances=(), environment=nothing) + +Create a model from `Object` and `ObjectInstance` values. Global applications +and applications mounted from object instances are compiled through the same +composite-model/object dependency graph. +""" +function CompositeModel( + items::Union{Object,ObjectInstance}...; + applications=(), + instances=(), + environment=nothing, + source_adapter=nothing, +) + objects, mounted_instances = _collect_model_items(items, instances) + instance_ids = _prepare_object_instances!(objects, mounted_instances) + mounted_applications = _mount_object_instance_applications(mounted_instances, instance_ids) + normalized_applications = collect(Any, _as_tuple(applications)) + append!(normalized_applications, mounted_applications) + model = CompositeModel( + ObjectRegistry(), + normalized_applications, + environment, + mounted_instances, + source_adapter, + nothing, + nothing, + true, + true, + nothing, + Set{ObjectId}(), + 0, + 0, + ) + return _register_model_objects!(model, objects) +end + +""" + CompositeModel(template::CompositeModelTemplate; + root, objects=(), name=nothing, overrides=NamedTuple(), + object_overrides=(), applications=(), environment=nothing) + +Build an executable composite model from a reusable template mounted on one +concrete object subtree. `root` may be the owned root `Object`, or its id when +the root is included in `objects`. When `name` is omitted, it is inferred from +the root object's name or id. + +This constructor is syntax lowering for an [`ObjectInstance`](@ref) passed to +the regular [`CompositeModel`](@ref) constructor. Use explicit +`ObjectInstance` values when the same composite model contains several mounted +templates. +""" +function CompositeModel( + template::CompositeModelTemplate; + root, + objects=(), + name=nothing, + overrides=NamedTuple(), + object_overrides=(), + applications=(), + environment=nothing, + source_adapter=nothing, +) + inferred_name = if !isnothing(name) + Symbol(name) + elseif root isa Object && !isnothing(root.name) + root.name + elseif root isa Object + Symbol(root.id.value) + else + Symbol(ObjectId(root).value) + end + instance = ObjectInstance( + inferred_name, + template; + root=root, + objects=objects, + overrides=overrides, + object_overrides=object_overrides, + ) + return CompositeModel( + instance; + applications=applications, + environment=environment, + source_adapter=source_adapter, + ) +end + +""" + CompositeModel(model::AbstractModel, models::AbstractModel...; + status=NamedTuple(), id=:scene, scale=:Scene, kind=:scene, + name=id, environment=nothing, timestep=nothing) + +Construct a concise one-object simulation. This is syntax lowering only: it +creates one ordinary [`Object`](@ref), one normal `ModelSpec` per model, and a +regular [`CompositeModel`](@ref). The returned model therefore uses the same compiler, +scheduler, diagnostics, lifecycle, and output system as explicitly assembled +composite models. + +Use explicit `Object`, `ModelSpec`, and selector construction when applications +need names, different cadences, explicit coupling, or different target sets. +Use `timestep` to apply one common cadence to every supplied model. +""" +function CompositeModel( + model::AbstractModel, + models::AbstractModel...; + status=NamedTuple(), + id=:scene, + scale=:Scene, + kind=:scene, + name=id, + environment=nothing, + timestep=nothing, +) + object_name = isnothing(name) ? nothing : Symbol(string(name)) + object_status = if status isa Status || isnothing(status) + status + elseif status isa Union{NamedTuple,AbstractDict,Base.Pairs} + Status((; (Symbol(key) => value for (key, value) in pairs(status))...)) + else + error( + "One-object `CompositeModel(...; status=...)` requires a `Status`, `NamedTuple`, ", + "`AbstractDict`, `Base.Pairs`, or `nothing`, got `$(typeof(status))`." + ) + end + selector = isnothing(object_name) ? One(scale=scale) : One(name=object_name) + applications = map((model, models...)) do application_model + application = ModelSpec(application_model) |> AppliesTo(selector) + isnothing(timestep) ? application : application |> TimeStep(timestep) + end + return CompositeModel( + Object( + id; + scale=scale, + kind=kind, + name=object_name, + status=object_status, + ); + applications=applications, + environment=environment, + ) +end + +function _mtg_attribute(node, key::Symbol, default=nothing) + try + return node[key] + catch + return default + end +end + +""" + objects_from_mtg(root; id=node_id, scale=symbol, kind=..., species=..., + name=..., geometry=..., status=...) + +Adapt one MTG subtree to model `Object` values. The MTG is traversed once; +node ids and parent relations become stable model-object identities and +relations. Accessors may attach labels, geometry, and existing status objects +without prescribing a plant architecture. +""" +function objects_from_mtg( + root::MultiScaleTreeGraph.Node; + id=node_id, + scale=symbol, + kind=node -> _mtg_attribute(node, :kind, nothing), + species=node -> _mtg_attribute(node, :species, nothing), + name=node -> _mtg_attribute(node, :name, nothing), + geometry=node -> _mtg_attribute(node, :geometry, nothing), + status=node -> _mtg_attribute(node, :plantsimengine_status, nothing), +) + adapter = MTGObjectAdapter( + id, + scale, + kind, + species, + name, + geometry, + status, + Ref(MultiScaleTreeGraph.max_id(root)), + ) + return _objects_from_mtg(root, adapter) +end + +function _objects_from_mtg(root::MultiScaleTreeGraph.Node, adapter::MTGObjectAdapter) + objects = Object[] + MultiScaleTreeGraph.traverse!(root) do node + node_parent = parent(node) + parent_id = node === root || isnothing(node_parent) ? nothing : adapter.id(node_parent) + push!( + objects, + Object( + adapter.id(node); + scale=adapter.scale(node), + kind=adapter.kind(node), + species=adapter.species(node), + name=adapter.name(node), + parent=parent_id, + geometry=adapter.geometry(node), + status=adapter.status(node), + ), + ) + end + return objects +end + +""" + CompositeModel(root::MultiScaleTreeGraph.Node; applications=(), instances=(), + environment=nothing, id=node_id, scale=symbol, status=..., ...) + +Build a unified model directly from an MTG subtree. The MTG accessors are +retained and reused by [`add_organ!`](@ref) when the topology grows. +""" +function CompositeModel( + root::MultiScaleTreeGraph.Node; + applications=(), + instances=(), + environment=nothing, + id=node_id, + scale=symbol, + kind=node -> _mtg_attribute(node, :kind, nothing), + species=node -> _mtg_attribute(node, :species, nothing), + name=node -> _mtg_attribute(node, :name, nothing), + geometry=node -> _mtg_attribute(node, :geometry, nothing), + status=node -> _mtg_attribute(node, :plantsimengine_status, nothing), +) + adapter = MTGObjectAdapter( + id, + scale, + kind, + species, + name, + geometry, + status, + Ref(MultiScaleTreeGraph.max_id(root)), + ) + objects = _objects_from_mtg(root, adapter) + return CompositeModel( + objects...; + applications=applications, + instances=instances, + environment=environment, + source_adapter=adapter, + ) +end + +function _mark_environment_bindings_dirty!(model::CompositeModel, object_id::Union{Nothing,ObjectId}=nothing) + if isnothing(object_id) || isnothing(model.environment_binding_cache) + model.environment_binding_cache = nothing + model.environment_dirty_objects = nothing + elseif !isnothing(model.environment_dirty_objects) + push!(model.environment_dirty_objects, object_id) + end + model.environment_bindings_dirty = true + model.environment_revision += 1 + return model +end + +function _mark_bindings_dirty!(model::CompositeModel, object_id::Union{Nothing,ObjectId}=nothing) + if isnothing(object_id) + model.binding_cache = nothing + model.binding_dirty_objects = nothing + elseif !isnothing(model.binding_dirty_objects) + push!(model.binding_dirty_objects, object_id) + end + model.bindings_dirty = true + model.revision += 1 + return _mark_environment_bindings_dirty!(model, object_id) +end + +bindings_dirty(model::CompositeModel) = model.bindings_dirty +environment_bindings_dirty(model::CompositeModel) = model.environment_bindings_dirty +model_revision(model::CompositeModel) = model.revision +environment_revision(model::CompositeModel) = model.environment_revision +compiled_bindings(model::CompositeModel) = model.bindings_dirty ? nothing : model.binding_cache +compiled_environment_bindings(model::CompositeModel) = model.environment_binding_cache +mark_environment_binding_dirty!(model::CompositeModel) = _mark_environment_bindings_dirty!(model) +function mark_environment_binding_dirty!(model::CompositeModel, id) + object_id = ObjectId(id) + _model_object(model, object_id) + return _mark_environment_bindings_dirty!(model, object_id) +end +function mark_environment_binding_dirty!(model::CompositeModel, object::Object) + return mark_environment_binding_dirty!(model, object.id) +end + +function _push_index!(index::Dict{Symbol,Set{ObjectId}}, key, id::ObjectId) + isnothing(key) && return nothing + push!(get!(index, key, Set{ObjectId}()), id) + return nothing +end + +function _delete_index!(index::Dict{Symbol,Set{ObjectId}}, key, id::ObjectId) + isnothing(key) && return nothing + ids = get(index, key, nothing) + isnothing(ids) && return nothing + delete!(ids, id) + isempty(ids) && delete!(index, key) + return nothing +end + +function _index_object!(registry::ObjectRegistry, object::Object) + _push_index!(registry.by_scale, object.scale, object.id) + _push_index!(registry.by_kind, object.kind, object.id) + _push_index!(registry.by_species, object.species, object.id) + if !isnothing(object.name) + existing = get(registry.by_name, object.name, nothing) + if !isnothing(existing) && existing != object.id + error( + "CompositeModel object name `$(object.name)` is already used by object `$(existing.value)`." + ) + end + registry.by_name[object.name] = object.id + end + return nothing +end + +function _deindex_object!(registry::ObjectRegistry, object::Object) + _delete_index!(registry.by_scale, object.scale, object.id) + _delete_index!(registry.by_kind, object.kind, object.id) + _delete_index!(registry.by_species, object.species, object.id) + if !isnothing(object.name) && get(registry.by_name, object.name, nothing) == object.id + delete!(registry.by_name, object.name) + end + return nothing +end + +function _model_object(model::CompositeModel, id) + oid = ObjectId(id) + haskey(model.registry.objects, oid) || error("No model object with id `$(oid.value)`.") + return model.registry.objects[oid] +end + +function _instance_for_object(model::CompositeModel, id) + current_id = ObjectId(id) + while haskey(model.registry.objects, current_id) + for instance in model.instances + _instance_root_id(instance) == current_id && return instance + end + parent = model.registry.objects[current_id].parent + isnothing(parent) && return nothing + current_id = parent + end + return nothing +end + +function _apply_instance_labels!(object::Object, instance) + isnothing(instance) && return object + isnothing(object.kind) && (object.kind = instance.template.kind) + isnothing(object.species) && (object.species = instance.template.species) + return object +end + +""" + register_object!(model, object; parent=object.parent) + +Register a fully initialized [`Object`](@ref) in `model`. Structural bindings +are marked dirty and become visible to execution after the next lifecycle +refresh boundary. Prefer [`add_organ!`](@ref) for MTG-backed growth. +""" +function register_object!(model::CompositeModel, object::Object; parent=object.parent) + registry = model.registry + haskey(registry.objects, object.id) && error("CompositeModel already contains object id `$(object.id.value)`.") + parent_id = isnothing(parent) ? nothing : ObjectId(parent) + if !isnothing(parent_id) && !haskey(registry.objects, parent_id) + error("No model object with id `$(parent_id.value)`.") + end + if !isnothing(object.name) + existing = get(registry.by_name, object.name, nothing) + isnothing(existing) || error( + "CompositeModel object name `$(object.name)` is already used by object `$(existing.value)`." + ) + end + instance = isnothing(parent_id) ? nothing : _instance_for_object(model, parent_id) + _apply_instance_labels!(object, instance) + object.parent = parent_id + registry.objects[object.id] = object + _index_object!(registry, object) + if !isnothing(object.parent) + parent_object = registry.objects[object.parent] + object.id in parent_object.children || push!(parent_object.children, object.id) + end + _mark_bindings_dirty!(model, object.id) + return object +end + +function _status_data!(data::Dict{Symbol,Any}, values) + values === nothing && return data + source = values isa Status ? NamedTuple(values) : values + source isa Union{NamedTuple,AbstractDict,Base.Pairs} || error( + "Initial organ status must be a `Status`, `NamedTuple`, `AbstractDict`, ", + "`Base.Pairs`, or `nothing`, got `$(typeof(values))`." + ) + for (key, value) in pairs(source) + Symbol(key) == :plantsimengine_status && continue + data[Symbol(key)] = value + end + return data +end + +function _organ_status(adapter::MTGObjectAdapter, node, initial_status) + adapted_status = adapter.status(node) + data = Dict{Symbol,Any}() + _status_data!(data, MultiScaleTreeGraph.node_attributes(node)) + _status_data!(data, adapted_status) + data[:node] = node + _status_data!(data, initial_status) + data[:node] = node + return Status((; data...)) +end + +""" + add_organ!(parent, runtime, link, symbol, scale; index=0, id, attributes=(), + initial_status=(), kind=nothing, species=nothing, name=nothing) + +Create an MTG node and register its corresponding model object as one operation. +`runtime` may be a [`CompositeModel`](@ref), [`RunContext`](@ref), or +[`Simulation`](@ref). The model reuses the MTG accessors and status +initializer supplied when it was constructed, then overlays `initial_status`. + +This is the public growth API. [`register_object!`](@ref) remains the low-level +registry operation for callers that already own a fully initialized `Object`. +""" +function add_organ!( + parent_node::MultiScaleTreeGraph.Node, + runtime, + link, + organ_symbol, + mtg_scale::Integer; + index::Integer=0, + id=nothing, + attributes=NamedTuple(), + initial_status=NamedTuple(), + kind=nothing, + species=nothing, + name=nothing, +) + model = runtime_model(runtime) + adapter = model.source_adapter + adapter isa MTGObjectAdapter || error( + "`add_organ!` requires a model constructed from an MTG. Use ", + "`register_object!` for composite models built directly from `Object` values." + ) + parent_id = ObjectId(adapter.id(parent_node)) + _model_object(model, parent_id) + root = MultiScaleTreeGraph.get_root(parent_node) + node_id = if isnothing(id) + adapter.max_node_id[] += 1 + else + explicit_id = Int(id) + adapter.max_node_id[] = max(adapter.max_node_id[], explicit_id) + explicit_id + end + isnothing(MultiScaleTreeGraph.get_node(root, node_id)) || error( + "MTG node id `$(node_id)` already exists." + ) + + node = MultiScaleTreeGraph.Node( + node_id, + parent_node, + MultiScaleTreeGraph.NodeMTG( + Symbol(link), + Symbol(organ_symbol), + Int(index), + Int(mtg_scale), + ), + attributes, + ) + try + status = _organ_status(adapter, node, initial_status) + node[:plantsimengine_status] = status + object = Object( + adapter.id(node); + scale=adapter.scale(node), + kind=isnothing(kind) ? adapter.kind(node) : kind, + species=isnothing(species) ? adapter.species(node) : species, + name=isnothing(name) ? adapter.name(node) : name, + parent=parent_id, + geometry=adapter.geometry(node), + status=status, + ) + register_object!(model, object) + return status + catch + MultiScaleTreeGraph.delete_node!(node) + rethrow() + end +end + +function _remove_child_link!(model::CompositeModel, parent_id, child_id::ObjectId) + isnothing(parent_id) && return nothing + parent_object = _model_object(model, parent_id) + filter!(!=(child_id), parent_object.children) + return nothing +end + +function _instance_roots_in_subtree(model::CompositeModel, root_id::ObjectId) + subtree_ids = Set(_descendant_ids(model, root_id)) + roots = ObjectId[ + _instance_root_id(instance) for instance in model.instances + if _instance_root_id(instance) in subtree_ids + ] + return _sort_object_ids!(roots) +end + +function _validate_mutable_object_subtree!(model::CompositeModel, object::Object, operation::Symbol) + instance_roots = _instance_roots_in_subtree(model, object.id) + isempty(instance_roots) && return nothing + error( + "Cannot $(operation) object `$(object.id.value)` because its subtree contains ", + "immutable ObjectInstance root(s) `$([id.value for id in instance_roots])`. ", + "Mutate ordinary instance descendants instead.", + ) +end + +function remove_object!(model::CompositeModel, id; recursive::Bool=true) + object = _model_object(model, id) + _validate_mutable_object_subtree!(model, object, :remove) + if !recursive && !isempty(object.children) + error("Cannot remove object `$(object.id.value)` with children unless `recursive=true`.") + end + for child in copy(object.children) + remove_object!(model, child; recursive=true) + end + _remove_child_link!(model, object.parent, object.id) + _deindex_object!(model.registry, object) + delete!(model.registry.objects, object.id) + _mark_bindings_dirty!(model) + return object +end + +function reparent_object!(model::CompositeModel, id, new_parent) + object = _model_object(model, id) + _validate_mutable_object_subtree!(model, object, :reparent) + new_parent_id = isnothing(new_parent) ? nothing : ObjectId(new_parent) + if !isnothing(new_parent_id) + haskey(model.registry.objects, new_parent_id) || error("No model object with id `$(new_parent_id.value)`.") + new_parent_id == object.id && error( + "Cannot reparent object `$(object.id.value)` to itself.", + ) + new_parent_id in _descendant_ids(model, object.id) && error( + "Cannot reparent object `$(object.id.value)` below its descendant `$(new_parent_id.value)`.", + ) + end + _remove_child_link!(model, object.parent, object.id) + object.parent = new_parent_id + if !isnothing(new_parent_id) + parent_object = _model_object(model, new_parent_id) + object.id in parent_object.children || push!(parent_object.children, object.id) + end + _mark_bindings_dirty!(model) + return object +end + +function move_object!(model::CompositeModel, id, geometry_or_position) + return update_geometry!(model, id, geometry_or_position) +end + +function update_geometry!(model::CompositeModel, id, geometry_or_position; invalidate_environment::Bool=true) + object = _model_object(model, id) + object.geometry = geometry_or_position + if invalidate_environment + _mark_environment_bindings_dirty!(model, object.id) + for descendant_id in _geometry_inheriting_descendants(model, object.id) + _mark_environment_bindings_dirty!(model, descendant_id) + end + end + return object +end + +function update_geometry!(object::Object, geometry_or_position) + object.geometry = geometry_or_position + return object +end + +geometry(object::Object) = object.geometry +geometry(status::Status) = hasproperty(status, :geometry) ? status.geometry : nothing +geometry(x) = hasproperty(x, :geometry) ? getproperty(x, :geometry) : nothing + +function _geometry_position(g::NamedTuple) + haskey(g, :position) && return getproperty(g, :position) + if haskey(g, :x) && haskey(g, :y) && haskey(g, :z) + return (x=g.x, y=g.y, z=g.z) + elseif haskey(g, :x) && haskey(g, :y) + return (x=g.x, y=g.y) + end + return nothing +end +_geometry_position(_) = nothing + +position(object::Object) = _geometry_position(geometry(object)) +position(status::Status) = _geometry_position(geometry(status)) + +_geometry_bounds(g::NamedTuple) = haskey(g, :bounds) ? getproperty(g, :bounds) : nothing +_geometry_bounds(_) = nothing +bounds(object::Object) = _geometry_bounds(geometry(object)) +bounds(status::Status) = _geometry_bounds(geometry(status)) + +function _geometry_inheriting_descendants(model::CompositeModel, root_id::ObjectId) + ids = ObjectId[] + for child_id in _model_object(model, root_id).children + child = _model_object(model, child_id) + isnothing(geometry(child)) || continue + push!(ids, child_id) + append!(ids, _geometry_inheriting_descendants(model, child_id)) + end + return ids +end + +function refresh_bindings!(model::CompositeModel, specs=model.applications; force::Bool=false) + uses_model_applications = specs === model.applications + if !uses_model_applications + return compile_composite_model(model, specs) + end + if force || model.bindings_dirty || isnothing(model.binding_cache) + can_extend = !force && + !isnothing(model.binding_cache) && + !isnothing(model.binding_dirty_objects) && + !isempty(model.binding_dirty_objects) + model.binding_cache = can_extend ? + _extend_compiled_scene( + model, + model.binding_cache, + model.binding_dirty_objects, + ) : compile_composite_model(model, model.applications) + model.bindings_dirty = false + model.binding_dirty_objects = Set{ObjectId}() + end + return model.binding_cache +end + +function refresh_environment_bindings!(model::CompositeModel, compiled=refresh_bindings!(model); force::Bool=false) + if force || model.environment_bindings_dirty || isnothing(model.environment_binding_cache) + if !force && + !isnothing(model.environment_binding_cache) && + !isnothing(model.environment_dirty_objects) + model.environment_binding_cache = _refresh_environment_bindings_for_objects( + model, + compiled, + model.environment_binding_cache, + model.environment_dirty_objects, + ) + else + model.environment_binding_cache = compile_environment_bindings(model, compiled) + end + model.environment_bindings_dirty = false + model.environment_dirty_objects = Set{ObjectId}() + else + reconciled = _reconcile_environment_binding_metadata( + model, + compiled, + model.environment_binding_cache, + ) + model.environment_binding_cache = isnothing(reconciled) ? + compile_environment_bindings(model, compiled) : + reconciled + end + return model.environment_binding_cache +end + +function object_ids(model::CompositeModel; scale=nothing, kind=nothing, species=nothing, name=nothing) + registry = model.registry + sets = Set{ObjectId}[] + isnothing(scale) || push!(sets, copy(get(registry.by_scale, Symbol(scale), Set{ObjectId}()))) + isnothing(kind) || push!(sets, copy(get(registry.by_kind, Symbol(kind), Set{ObjectId}()))) + isnothing(species) || push!(sets, copy(get(registry.by_species, Symbol(species), Set{ObjectId}()))) + if !isnothing(name) + id = get(registry.by_name, Symbol(name), nothing) + push!(sets, isnothing(id) ? Set{ObjectId}() : Set([id])) + end + isempty(sets) && return sort!(collect(keys(registry.objects)); by=id -> string(id.value)) + ids = reduce(intersect, sets) + return sort!(collect(ids); by=id -> string(id.value)) +end + +model_objects(model::CompositeModel; kwargs...) = [_model_object(model, id) for id in object_ids(model; kwargs...)] + +function _instance_object_ids(model::CompositeModel, instance::ObjectInstance) + root_id = _instance_root_id(instance) + haskey(model.registry.objects, root_id) || return ObjectId[] + return _sort_object_ids!(_descendant_ids(model, root_id)) +end + +function _object_instance_name(model::CompositeModel, object_id::ObjectId) + instance = _instance_for_object(model, object_id) + return isnothing(instance) ? nothing : instance.name +end + +function explain_objects(model::CompositeModel) + return [ + ( + id=object.id.value, + scale=object.scale, + kind=object.kind, + species=object.species, + name=object.name, + instance=_object_instance_name(model, object.id), + parent=isnothing(object.parent) ? nothing : object.parent.value, + children=[child.value for child in object.children], + has_geometry=!isnothing(object.geometry), + has_status=!isnothing(object.status), + n_applications=length(object.applications), + ) + for object in model_objects(model) + ] +end + +function _instance_application_ids(model::CompositeModel, instance::ObjectInstance) + prefix = string(instance.name, "__") + ids = Symbol[] + for application in model.applications + name = application_name(as_model_spec(application)) + isnothing(name) && continue + startswith(string(name), prefix) && push!(ids, name) + end + return sort!(ids; by=string) +end + +function explain_instances(model::CompositeModel) + return [ + ( + name=instance.name, + root_id=_instance_root_id(instance).value, + kind=instance.template.kind, + species=instance.template.species, + object_ids=[id.value for id in _instance_object_ids(model, instance)], + application_ids=_instance_application_ids(model, instance), + instance_overrides=sort!(Symbol[Symbol(name) for name in keys(instance.overrides)]; by=string), + object_overrides=[ + ( + object_id=override.object.value, + process=override.process, + application=override.application, + model_type=typeof(override.model), + ) + for override in instance.object_overrides + ], + parameters_type=typeof(instance.template.parameters), + parameters_shared_by_reference=true, + ) + for instance in model.instances + ] +end + +function _object_id_values(ids) + return [id.value for id in _sort_object_ids!(collect(ids))] +end + +function _label_scope_rows(model::CompositeModel, scope_type::Symbol, label::Symbol, index) + return [ + ( + scope_type=scope_type, + selector=label => key, + context=nothing, + root_id=nothing, + scale=label == :scale ? key : nothing, + kind=label == :kind ? key : nothing, + species=label == :species ? key : nothing, + name=nothing, + object_ids=_object_id_values(ids), + n_objects=length(ids), + ) + for (key, ids) in sort!(collect(index); by=pair -> string(first(pair))) + ] +end + +function explain_scopes(model::CompositeModel) + rows = NamedTuple[] + all_ids = object_ids(model) + push!( + rows, + ( + scope_type=:scene, + selector=SceneScope(), + context=nothing, + root_id=nothing, + scale=nothing, + kind=nothing, + species=nothing, + name=nothing, + object_ids=[id.value for id in all_ids], + n_objects=length(all_ids), + ), + ) + for object in model_objects(model) + push!( + rows, + ( + scope_type=:object_self, + selector=Self(), + context=object.id.value, + root_id=object.id.value, + scale=object.scale, + kind=object.kind, + species=object.species, + name=object.name, + object_ids=[object.id.value], + n_objects=1, + ), + ) + descendant_ids = _object_id_values(_descendant_ids(model, object.id)) + push!( + rows, + ( + scope_type=:object_subtree, + selector=Subtree(), + context=object.id.value, + root_id=object.id.value, + scale=object.scale, + kind=object.kind, + species=object.species, + name=object.name, + object_ids=descendant_ids, + n_objects=length(descendant_ids), + ), + ) + object_scope_name = object.id.value isa Symbol ? object.id.value : Symbol(string(object.id.value)) + scope_names = Symbol[object_scope_name] + isnothing(object.name) || push!(scope_names, object.name) + unique!(scope_names) + for scope_name in scope_names + push!( + rows, + ( + scope_type=:named_scope, + selector=Scope(scope_name), + context=nothing, + root_id=object.id.value, + scale=object.scale, + kind=object.kind, + species=object.species, + name=scope_name, + object_ids=descendant_ids, + n_objects=length(descendant_ids), + ), + ) + end + end + append!(rows, _label_scope_rows(model, :scale, :scale, model.registry.by_scale)) + append!(rows, _label_scope_rows(model, :kind, :kind, model.registry.by_kind)) + append!(rows, _label_scope_rows(model, :species, :species, model.registry.by_species)) + return rows +end diff --git a/src/composite_model/runtime_outputs.jl b/src/composite_model/runtime_outputs.jl new file mode 100644 index 000000000..a95b05a7a --- /dev/null +++ b/src/composite_model/runtime_outputs.jl @@ -0,0 +1,2181 @@ +struct OutputRetentionPlan + retain_all::Bool + temporal_dependencies::Set{Tuple{Symbol,Symbol}} + requested_outputs::Set{Tuple{Symbol,Symbol}} + dependency_horizons::Dict{Tuple{Symbol,Symbol},Float64} + retained_application_ids::Set{Symbol} + retained_outputs_by_application::Dict{Symbol,Vector{Symbol}} +end + +""" + RunContext + +Runtime context passed as the final argument to model kernels. Use +`runtime_model`, `call_targets`, and `run_call!` instead of inspecting its +fields. +""" +struct RunContext{CS,A,CT,TS,OR,C} + compiled::CS + application::A + object_id::ObjectId + calls::CT + temporal_streams::TS + output_retention::OR + time::Float64 + constants::C + publication_allowed::Bool +end + +struct CallTarget{CS,EB,A,M,S,TS,OR,C} + compiled::CS + environment_bindings::EB + application::A + object_id::ObjectId + model::M + status::S + temporal_streams::TS + output_retention::OR + time::Float64 + constants::C + publication_allowed::Bool +end + +""" + CallTargets <: AbstractVector{CallTarget} + +A cached vector-like view of the compiled targets for one declared hard call. +Retrieving it does not allocate a replacement collection. Obtain it with +[`call_targets`](@ref) or as the result of +[`run_call!(::RunContext, ::Symbol)`](@ref). +""" +mutable struct CallTargets{CS,EB,B,TS,OR,C} <: AbstractVector{CallTarget} + compiled::CS + environment_bindings::EB + binding::B + temporal_streams::TS + output_retention::OR + time::Float64 + constants::C + publication_allowed::Bool +end + +Base.IndexStyle(::Type{<:CallTargets}) = IndexLinear() +Base.eltype(::Type{<:CallTargets}) = CallTarget + +_runtime_call_targets(compiled, environment_bindings, ::Tuple{}, args...) = () + +function _runtime_call_targets( + compiled, + environment_bindings, + bindings::Tuple, + temporal_streams, + output_retention, + time, + constants, + publication_allowed, +) + target = CallTargets( + compiled, + environment_bindings, + first(bindings), + temporal_streams, + output_retention, + float(time), + constants, + publication_allowed, + ) + return ( + target, + _runtime_call_targets( + compiled, + environment_bindings, + Base.tail(bindings), + temporal_streams, + output_retention, + time, + constants, + publication_allowed, + )..., + ) +end + +abstract type AbstractExecutionBatch end +struct UnspecifiedModelMeteo end +const _UNSPECIFIED_SCENE_METEO = UnspecifiedModelMeteo() +const _SCENE_RAW_METEO_CACHE_ID = Symbol("#raw_global_meteo") + +struct CompiledExecutionTarget{M,S,MB,IB,EB} + object_id::ObjectId + model::M + status::S + models::MB + input_bindings::IB + environment_binding::EB +end + +struct CompiledExecutionBatch{A,T<:AbstractVector} <: AbstractExecutionBatch + application::A + targets::T +end + +struct CompiledExecutionPlan{B} + batches::B + model_revision::Int + environment_revision::Int +end + +""" + Simulation + +Result of running a [`CompositeModel`](@ref). Use `outputs`, `collect_outputs`, +and the explanation helpers to inspect it. +""" +mutable struct Simulation{S,CS,EB,EP,OR,TS,R,RT,C} + model::S + compiled::CS + environment_bindings::EB + execution_plan::EP + output_retention::OR + temporal_streams::TS + output_requests::R + output_request_targets::RT + current_step::Int + constants::C +end + +""" + runtime_model(runtime) + +Return the live [`CompositeModel`](@ref) owned by a `CompositeModel`, [`RunContext`](@ref), +or [`Simulation`](@ref). Lifecycle-capable models should call this +accessor instead of reaching through runtime implementation fields. +""" +runtime_model(model::CompositeModel) = model +runtime_model(context::RunContext) = context.compiled.model +runtime_model(simulation::Simulation) = simulation.model +current_step(simulation::Simulation) = simulation.current_step + +outputs(sim::Simulation) = sim.temporal_streams + +# Diagnostics accept the live simulation handle without exposing its compiled +# representation. Views concerning topology and initialization use the current +# model; compiled views use the simulation's current compiled state. +explain_objects(sim::Simulation) = explain_objects(sim.model) +explain_instances(sim::Simulation) = explain_instances(sim.model) +explain_scopes(sim::Simulation) = explain_scopes(sim.model) +explain_initialization(sim::Simulation) = explain_initialization(sim.model) +explain_applications(sim::Simulation) = explain_applications(sim.compiled) +explain_schedule(sim::Simulation) = explain_schedule(sim.compiled) +explain_bindings(sim::Simulation) = explain_bindings(sim.compiled) +explain_calls(sim::Simulation) = explain_calls(sim.compiled) +explain_model_bundles(sim::Simulation) = explain_model_bundles(sim.compiled) +explain_writers(sim::Simulation) = explain_writers(sim.compiled) +explain_environment_bindings(sim::Simulation) = + explain_environment_bindings(sim.environment_bindings) + +function _compiled_application_by_id(compiled::CompiledCompositeModel, id::Symbol) + application = get(compiled.applications_by_id, id, nothing) + isnothing(application) && error("No compiled model application with id `$(id)`.") + return application +end + +function _environment_binding_for(env_bindings::CompiledEnvironmentBindings, application_id::Symbol, object_id::ObjectId) + return get(env_bindings.by_target, (application_id, object_id), nothing) +end + +function _model_object_status(model::CompositeModel, object_id::ObjectId) + object = _model_object(model, object_id) + object.status isa Status || error( + "CompositeModel object `$(object_id.value)` has no `Status`; model runtime requires status-backed objects." + ) + return object.status +end + +function _set_status_if_present!(status::Status, variable::Symbol, value) + variable in propertynames(status) || error( + "Cannot materialize input `$(variable)` because consumer status has no such variable." + ) + status[variable] = value + return status +end + +_model_stream_key(application_id::Symbol, object_id::ObjectId, variable::Symbol) = + (application_id, object_id, variable) + +function _model_retain_output( + retention::OutputRetentionPlan, + application_id::Symbol, + variable::Symbol, +) + retention.retain_all && return true + key = (application_id, variable) + return key in retention.temporal_dependencies || + key in retention.requested_outputs +end + +function _model_retain_application( + retention::OutputRetentionPlan, + application_id::Symbol, +) + return retention.retain_all || application_id in retention.retained_application_ids +end + +_model_retain_application(::Nothing, application_id::Symbol) = true + +_model_retain_output(::Nothing, application_id::Symbol, variable::Symbol) = true + +function _model_prune_dependency_stream!( + samples, + retention::OutputRetentionPlan, + application_id::Symbol, + variable::Symbol, + time::Real, +) + retention.retain_all && return samples + key = (application_id, variable) + key in retention.requested_outputs && return samples + key in retention.temporal_dependencies || return samples + horizon = get(retention.dependency_horizons, key, 0.0) + if horizon <= 0.0 + length(samples) > 1 && deleteat!(samples, 1:(length(samples) - 1)) + return samples + end + cutoff = float(time) - horizon + 1.0 + filter!(sample -> sample[1] >= cutoff - 1.0e-8, samples) + return samples +end + +_model_prune_dependency_stream!(samples, ::Nothing, application_id, variable, time) = + samples + +function _model_publish_outputs!( + streams, + application::CompiledModelApplication, + object_id::ObjectId, + status, + time::Real, + retention=nothing, + retained_variables=nothing, +) + isnothing(streams) && return nothing + _model_retain_application(retention, application.id) || return nothing + variables = isnothing(retained_variables) ? keys(outputs_(application.spec)) : retained_variables + for variable in variables + var = Symbol(variable) + isnothing(retained_variables) && + !_model_retain_output(retention, application.id, var) && continue + hasproperty(status, var) || error( + "Application `$(application.id)` declares output `$(var)`, but object ", + "`$(object_id.value)` status has no such variable." + ) + key = _model_stream_key(application.id, object_id, var) + value = getproperty(status, var) + samples = get(streams, key, nothing) + if isnothing(samples) + samples = Tuple{Float64,typeof(value)}[] + streams[key] = samples + elseif !(value isa fieldtype(eltype(samples), 2)) + error( + "Output `$(application.id).$(var)` on object `$(object_id.value)` changed ", + "value type from `$(fieldtype(eltype(samples), 2))` to `$(typeof(value))`. ", + "CompositeModel temporal streams require a stable output type." + ) + end + sample_time = float(time) + if !isempty(samples) && + isapprox(last(samples)[1], sample_time; atol=1.0e-8, rtol=0.0) + pop!(samples) + elseif !isempty(samples) && last(samples)[1] > sample_time + filter!( + sample -> !isapprox(sample[1], sample_time; atol=1.0e-8, rtol=0.0), + samples, + ) + end + push!(samples, (sample_time, value)) + _model_prune_dependency_stream!( + samples, + retention, + application.id, + var, + time, + ) + end + return nothing +end + +function _model_latest_sample(samples, time::Real) + requested_time = float(time) + for index in reverse(eachindex(samples)) + sample_t, value = samples[index] + sample_t <= requested_time && return value + end + return nothing +end + +function _model_linear_value(v_left, v_right, α) + applicable(-, v_right, v_left) || return nothing + delta = v_right - v_left + increment = if applicable(*, α, delta) + α * delta + elseif applicable(*, delta, α) + delta * α + else + return nothing + end + applicable(+, v_left, increment) || return nothing + return v_left + increment +end + +function _model_sample_last_le(samples, time::Real) + low = firstindex(samples) + high = lastindex(samples) + found = nothing + while low <= high + middle = low + ((high - low) >>> 1) + if samples[middle][1] <= time + found = middle + low = middle + 1 + else + high = middle - 1 + end + end + return found +end + +function _model_sample_first_ge(samples, time::Real) + low = firstindex(samples) + high = lastindex(samples) + found = nothing + while low <= high + middle = low + ((high - low) >>> 1) + if samples[middle][1] >= time + found = middle + high = middle - 1 + else + low = middle + 1 + end + end + return found +end + +function _model_interpolated_sample(samples, time::Real, policy::Interpolate) + isempty(samples) && return nothing + t = float(time) + prev_idx = _model_sample_last_le(samples, t + 1.0e-8) + next_idx = _model_sample_first_ge(samples, t - 1.0e-8) + + if !isnothing(prev_idx) && !isnothing(next_idx) + t_prev, v_prev = samples[prev_idx] + t_next, v_next = samples[next_idx] + isapprox(t_prev, t_next; atol=1.0e-8, rtol=0.0) && return v_prev + policy.mode == :hold && return v_prev + α = (t - t_prev) / (t_next - t_prev) + interpolated = _model_linear_value(v_prev, v_next, α) + return isnothing(interpolated) ? v_prev : interpolated + end + + if !isnothing(prev_idx) + t_last, v_last = samples[prev_idx] + if policy.extrapolation == :linear && prev_idx >= 2 + t_prev, v_prev = samples[prev_idx - 1] + if !isapprox(t_last, t_prev; atol=1.0e-8, rtol=0.0) + α = (t - t_last) / (t_last - t_prev) + extrapolated = _model_linear_value(v_prev, v_last, one(α) + α) + !isnothing(extrapolated) && return extrapolated + end + end + return v_last + end + + return first(samples)[2] +end + +function _model_window_segments(samples, t_start::Real, t_end::Real, base_step_seconds::Real) + value_type = fieldtype(eltype(samples), 2) + isempty(samples) && return (value_type[], Float64[]) + window_start = float(t_start) + window_stop = float(t_end) + 1.0 + first_index = findlast(sample -> sample[1] < window_start, samples) + first_index = isnothing(first_index) ? firstindex(samples) : first_index + + values = value_type[] + durations = Float64[] + for index in first_index:lastindex(samples) + sample_t, value = samples[index] + sample_t >= window_stop && break + next_t = index == lastindex(samples) ? window_stop : samples[index + 1][1] + segment_start = max(float(sample_t), window_start) + segment_stop = min(float(next_t), window_stop) + segment_stop > segment_start || continue + push!(values, value) + push!(durations, (segment_stop - segment_start) * float(base_step_seconds)) + end + return values, durations +end + +function _model_window_reduce(values, durations, policy) + isempty(values) && return 0.0 + reducer = policy isa Integrate ? policy.reducer : (policy isa Aggregate ? policy.reducer : PlantMeteo.SumReducer()) + f = _normalize_policy_reducer(reducer) + applicable(f, values, durations) && return f(values, durations) + applicable(f, values) && return f(values) + reducer isa PlantMeteo.SumReducer && return sum(values) + reducer isa PlantMeteo.MeanReducer && return Statistics.mean(values) + reducer isa PlantMeteo.MinReducer && return minimum(values) + reducer isa PlantMeteo.MaxReducer && return maximum(values) + reducer isa PlantMeteo.FirstReducer && return first(values) + reducer isa PlantMeteo.LastReducer && return last(values) + error( + "Reducer `$(reducer)` is not callable on model temporal input values for policy ", + "`$(typeof(policy))`. Expected `(values)` or `(values, durations_seconds)`." + ) +end + +function _model_duration_steps(duration, timeline) + if duration isa Dates.Period || duration isa Dates.CompoundPeriod || duration isa Real + seconds = _duration_to_seconds(duration) + isnothing(seconds) && return nothing + steps = seconds / timeline.base_step_seconds + steps >= 1.0 || error( + "Input window `$(duration)` is shorter than the model base step ", + "($(timeline.base_step_seconds) seconds)." + ) + return steps + end + return nothing +end + +function _model_input_window_steps(binding::CompiledModelInputBinding, application::CompiledModelApplication, timeline) + explicit = _model_duration_steps(binding.window, timeline) + !isnothing(explicit) && return explicit + binding.policy isa Union{Integrate,Aggregate} && return float(application.clock.dt) + return 1.0 +end + +function _model_temporal_source_value( + streams, + application_id::Symbol, + source_id::ObjectId, + source_var::Symbol, + time::Real, + policy, + t_start::Real, + timeline, +) + samples = get(streams, _model_stream_key(application_id, source_id, source_var), nothing) + isnothing(samples) && return policy isa Union{Integrate,Aggregate} ? 0.0 : nothing + if policy isa HoldLast + return _model_latest_sample(samples, time) + elseif policy isa PreviousTimeStep + return _model_latest_sample(samples, float(time) - 1.0) + elseif policy isa Union{Integrate,Aggregate} + values, durations = _model_window_segments( + samples, + t_start, + time, + timeline.base_step_seconds, + ) + return _model_window_reduce(values, durations, policy) + elseif policy isa Interpolate + return _model_interpolated_sample(samples, time, policy) + end + error("Unsupported model temporal input policy `$(typeof(policy))`.") +end + +function _model_temporal_source_value( + streams, + application_id::Nothing, + source_id::ObjectId, + source_var::Symbol, + time::Real, + policy, + t_start::Real, + timeline, +) + policy isa PreviousTimeStep && return nothing + error( + "Temporal model input from `$(source_id.value).$(source_var)` has no ", + "source application for policy `$(typeof(policy))`." + ) +end + +function _model_temporal_source_application(compiled::CompiledCompositeModel, binding::CompiledModelInputBinding, source_id::ObjectId) + if binding.policy isa PreviousTimeStep && isempty(binding.source_application_ids) + return nothing + end + isempty(binding.source_application_ids) && error( + "Temporal model input `$(binding.input)` from `$(source_id.value).$(binding.source_var)` ", + "has no resolved source application. Name the producer and add " * + "`application=...` to `Inputs(...)`." + ) + length(binding.source_application_ids) == 1 && + return only(binding.source_application_ids) + matches = Symbol[ + application_id for application_id in binding.source_application_ids + if source_id in _compiled_application_by_id(compiled, application_id).target_ids + ] + if length(matches) == 1 + return only(matches) + elseif isempty(matches) + binding.policy isa PreviousTimeStep && return nothing + error( + "Temporal model input `$(binding.input)` from `$(source_id.value).$(binding.source_var)` ", + "has no source application matching object `$(source_id.value)`." + ) + end + if binding.policy isa PreviousTimeStep + positions = Dict(application_id => index for (index, application_id) in pairs(compiled.application_order)) + return last(sort(matches; by=application_id -> get(positions, application_id, 0))) + end + error( + "Temporal model input `$(binding.input)` from `$(source_id.value).$(binding.source_var)` ", + "has ambiguous source applications `$(matches)`. Add `application=...` to `Inputs(...)`." + ) +end + +function _model_temporal_input_value( + compiled::CompiledCompositeModel, + binding::CompiledModelInputBinding, + application::CompiledModelApplication, + status::Status, + streams, + time::Real, + timeline, +) + window_steps = _model_input_window_steps(binding, application, timeline) + t_start = float(time) - float(window_steps) + 1.0 + initial = _input_value(binding.carrier) + isnothing(initial) && (initial = status[binding.input]) + if binding.multiplicity == :many + values = [ + _model_temporal_source_value( + streams, + _model_temporal_source_application(compiled, binding, source_id), + source_id, + binding.source_var, + time, + binding.policy, + t_start, + timeline, + ) + for source_id in binding.source_ids + ] + if binding.policy isa PreviousTimeStep + initial_values = initial isa AbstractVector ? initial : () + return [ + isnothing(value) && index <= length(initial_values) ? initial_values[index] : value + for (index, value) in pairs(values) + ] + end + return values + end + source_id = only(binding.source_ids) + value = _model_temporal_source_value( + streams, + _model_temporal_source_application(compiled, binding, source_id), + source_id, + binding.source_var, + time, + binding.policy, + t_start, + timeline, + ) + isnothing(value) && binding.policy isa PreviousTimeStep && return initial + isnothing(value) && error( + "No temporal model value available for input `$(binding.input)` from ", + "`$(source_id.value).$(binding.source_var)` at t=$(time)." + ) + return value +end + +function _model_temporal_input_value( + compiled::CompiledCompositeModel, + binding::CompiledModelInputBinding, + application::CompiledModelApplication, + streams, + time::Real, + timeline, +) + status = _model_object_status(compiled.model, binding.consumer_id) + return _model_temporal_input_value( + compiled, + binding, + application, + status, + streams, + time, + timeline, + ) +end + +function _model_assign_input_value!(status::Status, variable::Symbol, value) + variable in propertynames(status) || error( + "Cannot materialize input `$(variable)` because consumer status has no such variable." + ) + current = status[variable] + if current isa RefVector && value isa AbstractVector + length(current) != length(value) && resize!(current, length(value)) + for i in eachindex(value) + current[i] = value[i] + end + return status + end + status[variable] = value + return status +end + +function _materialize_model_inputs!( + compiled::CompiledCompositeModel, + application::CompiledModelApplication, + object_id::ObjectId, + streams=nothing, + time::Real=1, +) + status = _model_object_status(compiled.model, object_id) + bindings = get(compiled.input_bindings_by_target, (application.id, object_id), ()) + timeline = nothing + for binding in bindings + if binding.carrier_hint == :temporal_stream + isnothing(streams) && continue + isnothing(timeline) && (timeline = compiled.timeline) + value = _model_temporal_input_value( + compiled, + binding, + application, + status, + streams, + time, + timeline, + ) + _model_assign_input_value!(status, binding.input, value) + end + end + return status +end + +function _materialize_model_inputs!( + status::Status, + bindings::Tuple, + compiled::CompiledCompositeModel, + application::CompiledModelApplication, + streams=nothing, + time::Real=1, +) + timeline = nothing + for binding in bindings + if binding.carrier_hint == :temporal_stream + isnothing(streams) && continue + isnothing(timeline) && (timeline = compiled.timeline) + value = _model_temporal_input_value( + compiled, + binding, + application, + status, + streams, + time, + timeline, + ) + _model_assign_input_value!(status, binding.input, value) + end + end + return status +end + +function _model_meteo_for_model( + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + object_id::ObjectId, + time::Real, +) + binding = _environment_binding_for(env_bindings, application.id, object_id) + return _model_meteo_for_binding(env_bindings, application, binding, time) +end + +function _model_meteo_for_binding( + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + binding, + time::Real, +) + isnothing(binding) && return nothing + isnothing(binding.backend) && return nothing + if binding.backend isa GlobalConstant + step = Int(round(time)) + key = (application.id, step) + haskey(env_bindings.sample_cache, key) && + return env_bindings.sample_cache[key] + raw_key = (_SCENE_RAW_METEO_CACHE_ID, step) + raw_row = get!(env_bindings.sample_cache, raw_key) do + _meteo_row_at_step(environment_meteo(binding.backend), step) + end + sampler = get(env_bindings.samplers_by_application, application.id, nothing) + if !isnothing(sampler) + sampled = _sample_meteo_for_model( + sampler, + raw_row, + step, + application.clock, + application.spec, + ) + env_bindings.sample_cache[key] = sampled + return sampled + end + bindings = meteo_bindings(application.spec) + has_bindings = bindings isa NamedTuple && !isempty(keys(bindings)) + environment_sources = _environment_source_overrides(application.spec) + if !has_bindings && isempty(keys(environment_sources)) + env_bindings.sample_cache[key] = raw_row + return raw_row + end + sampled = sample_environment( + binding.backend, + binding.support, + time, + application.spec, + ) + env_bindings.sample_cache[key] = sampled + return sampled + end + return sample_environment(binding.backend, binding.support, time, application.spec) +end + +function _scatter_model_environment_outputs!( + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + object_id::ObjectId, + status, + time::Real, +) + isempty(keys(meteo_outputs_(application.spec))) && return nothing + binding = _environment_binding_for(env_bindings, application.id, object_id) + return _scatter_model_environment_outputs!( + application, + binding, + status, + time, + ) +end + +function _scatter_model_environment_outputs!( + application::CompiledModelApplication, + binding, + status, + time::Real, +) + isempty(keys(meteo_outputs_(application.spec))) && return nothing + isnothing(binding) && return nothing + isnothing(binding.backend) && return nothing + return scatter_environment_outputs!(binding.backend, binding.support, time, application.spec, status) +end + +function _push_model_model_entry!(pairs, names::Set{Symbol}, name::Symbol, model) + name in names && return nothing + push!(pairs, name => model) + push!(names, name) + return nothing +end + +function _append_model_model_dependencies!( + pairs, + names::Set{Symbol}, + applications_by_id, + call_bindings_by_target, + application::CompiledModelApplication, + object_id::ObjectId, + seen::Set{Tuple{Symbol,ObjectId}}, +) + key = (application.id, object_id) + key in seen && return nothing + push!(seen, key) + _push_model_model_entry!( + pairs, + names, + application.process, + _application_model(application, object_id), + ) + bindings = get(call_bindings_by_target, key, ()) + for binding in bindings + for application_id in binding.callee_application_ids + callee_application = get(applications_by_id, application_id, nothing) + isnothing(callee_application) && error( + "No compiled model application with id `$(application_id)`." + ) + matching_object_ids = ObjectId[ + callee_object_id for callee_object_id in binding.callee_object_ids + if callee_object_id in callee_application.target_ids + ] + # Old-style hard-dependency kernels expect one model object per + # process field. Multi-object model calls are exposed through + # `call_targets(extra, name)` instead. + length(matching_object_ids) == 1 || continue + _append_model_model_dependencies!( + pairs, + names, + applications_by_id, + call_bindings_by_target, + callee_application, + only(matching_object_ids), + seen, + ) + end + end + return nothing +end + +function _compile_model_model_bundle( + applications_by_id, + call_bindings_by_target, + application::CompiledModelApplication, + object_id::ObjectId, +) + pairs = Pair{Symbol,Any}[] + names = Set{Symbol}() + seen = Set{Tuple{Symbol,ObjectId}}() + _append_model_model_dependencies!( + pairs, + names, + applications_by_id, + call_bindings_by_target, + application, + object_id, + seen, + ) + return NamedTuple{Tuple(first(pair) for pair in pairs)}(Tuple(last(pair) for pair in pairs)) +end + +function _compile_model_model_bundles(applications, applications_by_id, call_bindings_by_target) + bundles = Dict{Tuple{Symbol,ObjectId},Any}() + for application in applications + for object_id in application.target_ids + bundles[(application.id, object_id)] = _compile_model_model_bundle( + applications_by_id, + call_bindings_by_target, + application, + object_id, + ) + end + end + return bundles +end + +function _model_models_for_application( + compiled::CompiledCompositeModel, + application::CompiledModelApplication, + object_id::ObjectId, +) + key = (application.id, object_id) + models = get(compiled.model_bundles_by_target, key, nothing) + isnothing(models) && error( + "No compiled model bundle for application `$(application.id)` on object `$(object_id.value)`." + ) + return models +end + +@inline function _run_model_application_with_calls!( + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + object_id::ObjectId, + status, + meteo_value, + calls, + time, + constants, + temporal_streams, + output_retention, + publish::Bool, +) + context = RunContext( + compiled, + application, + object_id, + calls, + temporal_streams, + output_retention, + float(time), + constants, + publish, + ) + model = _application_model(application, object_id) + models = _model_models_for_application(compiled, application, object_id) + run!(model, models, status, meteo_value, constants, context) + if publish + _scatter_model_environment_outputs!(env_bindings, application, object_id, status, time) + _model_publish_outputs!( + temporal_streams, + application, + object_id, + status, + time, + output_retention, + ) + end + return status +end + +function _run_model_application!( + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + object_id::ObjectId; + time::Real=1, + constants=nothing, + temporal_streams=nothing, + output_retention=nothing, + publish::Bool=true, + meteo=nothing, +) + status = _materialize_model_inputs!(compiled, application, object_id, temporal_streams, time) + meteo_value = isnothing(meteo) ? _model_meteo_for_model(env_bindings, application, object_id, time) : meteo + if isempty(compiled.call_bindings) + return _run_model_application_with_calls!( + compiled, + env_bindings, + application, + object_id, + status, + meteo_value, + (), + time, + constants, + temporal_streams, + output_retention, + publish, + ) + end + call_bindings = get( + compiled.call_bindings_by_target, + (application.id, object_id), + (), + ) + calls = _runtime_call_targets( + compiled, + env_bindings, + call_bindings, + temporal_streams, + output_retention, + time, + constants, + publish, + ) + return _run_model_application_with_calls!( + compiled, + env_bindings, + application, + object_id, + status, + meteo_value, + calls, + time, + constants, + temporal_streams, + output_retention, + publish, + ) +end + +function _run_model_execution_target_without_calls!( + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + target::CompiledExecutionTarget; + time::Real=1, + constants=nothing, + temporal_streams=nothing, + output_retention=nothing, + meteo=_UNSPECIFIED_SCENE_METEO, + publish_outputs::Bool=true, + scatter_outputs::Bool=true, + retained_outputs=nothing, +) + status = isempty(target.input_bindings) ? + target.status : + _materialize_model_inputs!( + target.status, + target.input_bindings, + compiled, + application, + temporal_streams, + time, + ) + meteo_value = meteo isa UnspecifiedModelMeteo ? + _model_meteo_for_binding( + env_bindings, + application, + target.environment_binding, + time, + ) : meteo + context = RunContext( + compiled, + application, + target.object_id, + (), + temporal_streams, + output_retention, + float(time), + constants, + true, + ) + run!(target.model, target.models, status, meteo_value, constants, context) + scatter_outputs && _scatter_model_environment_outputs!( + application, + target.environment_binding, + status, + time, + ) + publish_outputs && _model_publish_outputs!( + temporal_streams, + application, + target.object_id, + status, + time, + output_retention, + retained_outputs, + ) + return status +end + +function _run_model_execution_target!( + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + target::CompiledExecutionTarget; + time::Real=1, + constants=nothing, + temporal_streams=nothing, + output_retention=nothing, + meteo=_UNSPECIFIED_SCENE_METEO, + publish_outputs::Bool=true, + scatter_outputs::Bool=true, + retained_outputs=nothing, +) + isempty(compiled.call_bindings) && return _run_model_execution_target_without_calls!( + compiled, + env_bindings, + application, + target; + time=time, + constants=constants, + temporal_streams=temporal_streams, + output_retention=output_retention, + meteo=meteo, + publish_outputs=publish_outputs, + scatter_outputs=scatter_outputs, + retained_outputs=retained_outputs, + ) + status = isempty(target.input_bindings) ? + target.status : + _materialize_model_inputs!( + target.status, + target.input_bindings, + compiled, + application, + temporal_streams, + time, + ) + meteo_value = meteo isa UnspecifiedModelMeteo ? + _model_meteo_for_binding( + env_bindings, + application, + target.environment_binding, + time, + ) : meteo + call_bindings = get( + compiled.call_bindings_by_target, + (application.id, target.object_id), + (), + ) + calls = _runtime_call_targets( + compiled, + env_bindings, + call_bindings, + temporal_streams, + output_retention, + time, + constants, + true, + ) + context = RunContext( + compiled, + application, + target.object_id, + calls, + temporal_streams, + output_retention, + float(time), + constants, + true, + ) + run!(target.model, target.models, status, meteo_value, constants, context) + scatter_outputs && _scatter_model_environment_outputs!( + application, + target.environment_binding, + status, + time, + ) + publish_outputs && _model_publish_outputs!( + temporal_streams, + application, + target.object_id, + status, + time, + output_retention, + retained_outputs, + ) + return status +end + +function _run_model_execution_batch!( + batch::CompiledExecutionBatch, + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings; + time::Real=1, + constants=nothing, + temporal_streams=nothing, + output_retention=nothing, +) + _model_application_should_run(batch.application, time) || return nothing + shared_meteo = if !isempty(batch.targets) && + !isnothing(first(batch.targets).environment_binding) && + first(batch.targets).environment_binding.backend isa GlobalConstant + _model_meteo_for_binding( + env_bindings, + batch.application, + first(batch.targets).environment_binding, + time, + ) + else + _UNSPECIFIED_SCENE_METEO + end + publish_outputs = _model_retain_application(output_retention, batch.application.id) + scatter_outputs = !isempty(keys(meteo_outputs_(batch.application.spec))) + retained_outputs = output_retention isa OutputRetentionPlan ? + get( + output_retention.retained_outputs_by_application, + batch.application.id, + (), + ) : nothing + if isempty(compiled.call_bindings) + for target in batch.targets + _run_model_execution_target_without_calls!( + compiled, + env_bindings, + batch.application, + target; + time=time, + constants=constants, + temporal_streams=temporal_streams, + output_retention=output_retention, + meteo=shared_meteo, + publish_outputs=publish_outputs, + scatter_outputs=scatter_outputs, + retained_outputs=retained_outputs, + ) + end + return nothing + end + for target in batch.targets + _run_model_execution_target!( + compiled, + env_bindings, + batch.application, + target; + time=time, + constants=constants, + temporal_streams=temporal_streams, + output_retention=output_retention, + meteo=shared_meteo, + publish_outputs=publish_outputs, + scatter_outputs=scatter_outputs, + retained_outputs=retained_outputs, + ) + end + return nothing +end + +_model_application_should_run(application::CompiledModelApplication, t::Real) = + _should_run_at_time(application.clock, float(t)) + +function _manual_call_application_ids(compiled::CompiledCompositeModel) + ids = Set{Symbol}() + for binding in compiled.call_bindings + union!(ids, binding.callee_application_ids) + end + return ids +end + +function _compiled_model_execution_target( + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + object_id::ObjectId, +) + status = _model_object_status(compiled.model, object_id) + model = _application_model(application, object_id) + models = _model_models_for_application(compiled, application, object_id) + input_bindings = get( + compiled.input_bindings_by_target, + (application.id, object_id), + (), + ) + temporal_input_bindings = Tuple( + binding for binding in input_bindings + if binding.carrier_hint == :temporal_stream + ) + environment_binding = _environment_binding_for( + env_bindings, + application.id, + object_id, + ) + return CompiledExecutionTarget( + object_id, + model, + status, + models, + temporal_input_bindings, + environment_binding, + ) +end + +function _typed_model_execution_targets(targets, first_index::Int, last_index::Int) + target_type = typeof(targets[first_index]) + typed = Vector{target_type}(undef, last_index - first_index + 1) + for (destination, source) in enumerate(first_index:last_index) + typed[destination] = targets[source] + end + return typed +end + +function _append_model_execution_batches!( + batches, + application::CompiledModelApplication, + targets, +) + isempty(targets) && return batches + first_index = firstindex(targets) + target_type = typeof(targets[first_index]) + for index in (first_index + 1):lastindex(targets) + typeof(targets[index]) == target_type && continue + push!( + batches, + CompiledExecutionBatch( + application, + _typed_model_execution_targets(targets, first_index, index - 1), + ), + ) + first_index = index + target_type = typeof(targets[index]) + end + push!( + batches, + CompiledExecutionBatch( + application, + _typed_model_execution_targets(targets, first_index, lastindex(targets)), + ), + ) + return batches +end + +function compile_model_execution_plan( + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, +) + manual_application_ids = _manual_call_application_ids(compiled) + batches = AbstractExecutionBatch[] + for application in _ordered_model_applications(compiled) + application.id in manual_application_ids && continue + targets = Any[ + _compiled_model_execution_target( + compiled, + env_bindings, + application, + object_id, + ) + for object_id in application.target_ids + ] + _append_model_execution_batches!(batches, application, targets) + end + return CompiledExecutionPlan( + batches, + compiled.revision, + env_bindings.environment_revision, + ) +end + +function _extend_model_execution_plan( + plan::CompiledExecutionPlan, + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + added_object_ids, +) + added = Set(added_object_ids) + manual_application_ids = _manual_call_application_ids(compiled) + for application in _ordered_model_applications(compiled) + application.id in manual_application_ids && continue + for object_id in application.target_ids + object_id in added || continue + target = _compiled_model_execution_target( + compiled, + env_bindings, + application, + object_id, + ) + batch_index = findfirst(plan.batches) do batch + batch.application.id == application.id && eltype(batch.targets) === typeof(target) + end + isnothing(batch_index) && return compile_model_execution_plan(compiled, env_bindings) + push!(plan.batches[batch_index].targets, target) + end + end + return CompiledExecutionPlan( + plan.batches, + compiled.revision, + env_bindings.environment_revision, + ) +end + +function explain_execution_plan(plan::CompiledExecutionPlan) + return [ + ( + batch_index=index, + application_id=batch.application.id, + process=batch.application.process, + object_ids=[target.object_id.value for target in batch.targets], + batch_size=length(batch.targets), + target_type=eltype(batch.targets), + model_type=fieldtype(eltype(batch.targets), :model), + status_type=fieldtype(eltype(batch.targets), :status), + model_bundle_type=fieldtype(eltype(batch.targets), :models), + input_bindings_type=fieldtype(eltype(batch.targets), :input_bindings), + environment_binding_type=fieldtype( + eltype(batch.targets), + :environment_binding, + ), + inner_loop_dispatch=:concrete_homogeneous_batch, + ) + for (index, batch) in pairs(plan.batches) + ] +end + +function explain_execution_plan(sim::Simulation) + return explain_execution_plan(sim.execution_plan) +end + +function explain_execution_plan(model::CompositeModel) + compiled = refresh_bindings!(model) + environment_bindings = refresh_environment_bindings!(model, compiled) + return explain_execution_plan( + compile_model_execution_plan(compiled, environment_bindings), + ) +end + +function compile_model_output_retention( + compiled::CompiledCompositeModel, + output_requests; + retain_all::Bool=false, +) + temporal_dependencies = Set{Tuple{Symbol,Symbol}}() + dependency_horizons = Dict{Tuple{Symbol,Symbol},Float64}() + timeline = compiled.timeline + for binding in compiled.input_bindings + binding.carrier_hint == :temporal_stream || continue + consumer = _compiled_application_by_id(compiled, binding.application_id) + window_steps = _model_input_window_steps(binding, consumer, timeline) + for application_id in binding.source_application_ids + source = _compiled_application_by_id(compiled, application_id) + required = if binding.policy isa Union{Integrate,Aggregate} + float(window_steps) + max(0.0, float(source.clock.dt) - 1.0) + elseif binding.policy isa Union{Interpolate,PreviousTimeStep} + max(2.0, float(source.clock.dt) + 1.0) + else + 0.0 + end + key = (application_id, binding.source_var) + push!( + temporal_dependencies, + key, + ) + dependency_horizons[key] = max( + get(dependency_horizons, key, 0.0), + required, + ) + end + end + + requested_outputs = Set{Tuple{Symbol,Symbol}}() + names = Set{Symbol}() + for request in output_requests + request.name in names && error( + "Duplicate output request name `$(request.name)`. Request names must be unique." + ) + push!(names, request.name) + application = _model_request_application( + compiled.model, + compiled, + request, + ) + push!(requested_outputs, (application.id, request.var)) + end + retained_outputs_by_application = Dict{Symbol,Vector{Symbol}}() + retained_keys = if retain_all + Set( + (application.id, Symbol(variable)) + for application in compiled.applications + for variable in keys(outputs_(application.spec)) + ) + else + union(temporal_dependencies, requested_outputs) + end + for (application_id, variable) in retained_keys + push!( + get!(retained_outputs_by_application, application_id, Symbol[]), + variable, + ) + end + for variables in values(retained_outputs_by_application) + sort!(variables; by=string) + end + return OutputRetentionPlan( + retain_all, + temporal_dependencies, + requested_outputs, + dependency_horizons, + Set{Symbol}( + application_id + for (application_id, _) in union( + temporal_dependencies, + requested_outputs, + ) + ), + retained_outputs_by_application, + ) +end + +function _explain_output_retention(compiled::CompiledCompositeModel, plan) + keys_to_explain = if plan.retain_all + Set( + (application.id, Symbol(variable)) + for application in compiled.applications + for variable in keys(outputs_(application.spec)) + ) + else + union(plan.temporal_dependencies, plan.requested_outputs) + end + return [ + ( + application_id=application_id, + variable=variable, + reasons=plan.retain_all ? + (:all_outputs,) : + Tuple( + reason for (reason, keys) in ( + :temporal_dependency => plan.temporal_dependencies, + :output_request => plan.requested_outputs, + ) + if (application_id, variable) in keys + ), + retention_steps=plan.retain_all || + (application_id, variable) in plan.requested_outputs ? + nothing : + get( + plan.dependency_horizons, + (application_id, variable), + 0.0, + ), + current_target_count=length( + _compiled_application_by_id( + compiled, + application_id, + ).target_ids, + ), + ) + for (application_id, variable) in sort!( + collect(keys_to_explain); + by=key -> (string(first(key)), string(last(key))), + ) + ] +end + + +function explain_output_retention(sim::Simulation) + return _explain_output_retention(sim.compiled, sim.output_retention) +end + +function explain_output_retention(model::CompositeModel; outputs=:none) + compiled = refresh_bindings!(model) + output_requests, retain_all = _model_output_selection( + outputs, + _UNSPECIFIED_SCENE_OUTPUTS, + ) + plan = compile_model_output_retention( + compiled, + output_requests; + retain_all=retain_all, + ) + return _explain_output_retention(compiled, plan) +end + +@inline _find_call_targets(::Tuple{}, ::Val) = nothing + +@inline function _find_call_targets(calls::Tuple, ::Val{name}) where {name} + targets = first(calls) + _compiled_call_name(targets.binding) === name && return targets + return _find_call_targets(Base.tail(calls), Val(name)) +end + +Base.@constprop :aggressive function _model_call_targets( + context::RunContext, + name::Symbol, +) + found = _find_call_targets(context.calls, Val(name)) + if isnothing(found) + available = Symbol[targets.binding.call for targets in context.calls] + error( + "Application `$(context.application.id)` on object ", + "`$(context.object_id.value)` did not declare call `$(name)`. ", + "Declared calls: $(available).", + ) + end + return found +end + +function _call_target_matches(targets::CallTargets, application, object_id::ObjectId) + binding = targets.binding + return (binding.multiplicity != :many && + length(binding.callee_application_ids) == 1) || + object_id in application.target_ids +end + +function Base.length(targets::CallTargets) + count = 0 + for application_id in targets.binding.callee_application_ids + application = _compiled_application_by_id(targets.compiled, application_id) + for object_id in targets.binding.callee_object_ids + _call_target_matches(targets, application, object_id) && (count += 1) + end + end + return count +end + +Base.size(targets::CallTargets) = (length(targets),) + +function _materialize_call(targets::CallTargets, application, object_id::ObjectId) + status = _model_object_status(targets.compiled.model, object_id) + return CallTarget( + targets.compiled, + targets.environment_bindings, + application, + object_id, + _application_model(application, object_id), + status, + targets.temporal_streams, + targets.output_retention, + targets.time, + targets.constants, + targets.publication_allowed, + ) +end + +function Base.getindex(targets::CallTargets, requested::Int) + checkbounds(targets, requested) + current = 0 + for application_id in targets.binding.callee_application_ids + application = _compiled_application_by_id(targets.compiled, application_id) + for object_id in targets.binding.callee_object_ids + _call_target_matches(targets, application, object_id) || continue + current += 1 + current == requested && return _materialize_call(targets, application, object_id) + end + end + throw(BoundsError(targets, requested)) +end + +function Base.iterate(targets::CallTargets, state::Tuple{Int,Int}=(1, 1)) + application_index, object_index = state + application_ids = targets.binding.callee_application_ids + object_ids = targets.binding.callee_object_ids + while application_index <= length(application_ids) + application = _compiled_application_by_id( + targets.compiled, + application_ids[application_index], + ) + while object_index <= length(object_ids) + object_id = object_ids[object_index] + object_index += 1 + _call_target_matches(targets, application, object_id) || continue + return ( + _materialize_call(targets, application, object_id), + (application_index, object_index), + ) + end + application_index += 1 + object_index = 1 + end + return nothing +end + +""" + call_targets(context::RunContext, name::Symbol) + +Return a cached, non-executing vector-like view of the targets declared for +`name` with `Calls(...)`. The collection is empty for an unresolved +`OptionalOne`, has one element for `One`, and contains every resolved target +for `Many`. + +Use this accessor with [`run_call!(::CallTarget)`](@ref) when targets need +different meteorology, selective execution, a controlled order, or separate +trial and accepted publication. +""" +Base.@constprop :aggressive function call_targets( + context::RunContext, + name::Symbol, +) + return _model_call_targets(context, name) +end + +""" + run_call!(target::CallTarget; publish=false, meteo=nothing) + +Run one manually selected model call. By default, the call mutates its target +status without publishing outputs or environment updates, which is suitable +for trial iterations. Pass `publish=true` once for the accepted state. When +`meteo` is provided, it is forwarded directly to this target instead of using +its compiled environment binding. The method returns the same `CallTarget`. + +Publication permission is inherited through the call stack. A descendant +cannot publish outputs or environment writes while any ancestor is running as +a trial. + +For fine-grained control, obtain a collection with [`call_targets`](@ref), then +select or iterate its targets: + +```julia +targets = call_targets(extra, :leaf_energy) +for (target, meteo) in zip(targets, trial_meteo) + run_call!(target; meteo=meteo, publish=false) +end +for (target, meteo) in zip(targets, accepted_meteo) + run_call!(target; meteo=meteo, publish=true) +end +``` +""" +function run_call!(target::CallTarget; publish::Bool=false, meteo=nothing) + _run_model_application!( + target.compiled, + target.environment_bindings, + target.application, + target.object_id; + time=target.time, + constants=target.constants, + temporal_streams=target.temporal_streams, + output_retention=target.output_retention, + publish=publish && target.publication_allowed, + meteo=meteo, + ) + return target +end + +""" + run_call!(context::RunContext, name::Symbol; meteo=nothing, publish=false) + +Execute every target of the hard call declared as `name` and return its +[`CallTargets`](@ref) collection. The return shape is always vector-like: +`One` produces one element, `OptionalOne` zero or one, and `Many` zero or more. + +This is the convenient execute-all API. For finer-grained control over target +selection, order, per-target meteorology, iterative trial calls, or publication +of only an accepted result, use [`call_targets`](@ref) and execute individual +targets with [`run_call!(::CallTarget)`](@ref). +""" +function run_call!( + context::RunContext, + name::Symbol; + meteo=nothing, + publish::Bool=false, +) + targets = call_targets(context, name) + for target in targets + run_call!(target; meteo=meteo, publish=publish) + end + return targets +end + +function run_call!(context, name::Symbol; meteo=nothing, publish::Bool=false) + throw( + ArgumentError( + "Hard call `$(name)` requires the compiled RunContext passed to a model kernel; got $(typeof(context)).", + ), + ) +end + +struct _UnspecifiedModelOutputs end +const _UNSPECIFIED_SCENE_OUTPUTS = _UnspecifiedModelOutputs() + +function _model_output_selection(outputs, tracked_outputs) + outputs_specified = !(outputs isa _UnspecifiedModelOutputs) + tracked_specified = !(tracked_outputs isa _UnspecifiedModelOutputs) + outputs_specified && tracked_specified && error( + "Use `outputs=...`; do not pass both `outputs` and deprecated `tracked_outputs`.", + ) + + selection = if tracked_specified + Base.depwarn( + "`tracked_outputs` is deprecated; use `outputs=:all`, `outputs=:none`, or `outputs=requests`.", + :run!, + ) + isnothing(tracked_outputs) ? :all : tracked_outputs + elseif outputs_specified + outputs + else + :none + end + + selection === :all && return (OutputRequest[], true) + selection === :none && return (OutputRequest[], false) + selection isa Symbol && error( + "Unsupported output selection `$(selection)`. Use `:all`, `:none`, an `OutputRequest`, or a vector of requests.", + ) + requests = _normalize_output_requests(selection) + return requests, false +end + +function _refresh_simulation_runtime!(simulation::Simulation) + model = simulation.model + if bindings_dirty(model) + added_object_ids = isnothing(model.binding_dirty_objects) ? + nothing : copy(model.binding_dirty_objects) + simulation.compiled = refresh_bindings!(model) + simulation.environment_bindings = refresh_environment_bindings!( + model, + simulation.compiled, + ) + simulation.execution_plan = isnothing(added_object_ids) ? + compile_model_execution_plan( + simulation.compiled, + simulation.environment_bindings, + ) : _extend_model_execution_plan( + simulation.execution_plan, + simulation.compiled, + simulation.environment_bindings, + added_object_ids, + ) + elseif environment_bindings_dirty(model) + simulation.environment_bindings = refresh_environment_bindings!( + model, + simulation.compiled, + ) + simulation.execution_plan = compile_model_execution_plan( + simulation.compiled, + simulation.environment_bindings, + ) + end + return simulation +end + +function _continue_scene!(simulation::Simulation, steps::Integer) + steps >= 0 || error("`steps` must be non-negative, got $(steps).") + start_step = simulation.current_step + 1 + final_step = simulation.current_step + steps + for step in start_step:final_step + added_object_ids = bindings_dirty(simulation.model) && + !isnothing(simulation.model.binding_dirty_objects) ? + copy(simulation.model.binding_dirty_objects) : nothing + _refresh_simulation_runtime!(simulation) + _refresh_output_request_targets!(simulation, added_object_ids) + empty!(simulation.environment_bindings.sample_cache) + for batch in simulation.execution_plan.batches + _run_model_execution_batch!( + batch, + simulation.compiled, + simulation.environment_bindings; + time=step, + constants=simulation.constants, + temporal_streams=simulation.temporal_streams, + output_retention=simulation.output_retention, + ) + end + simulation.current_step = step + end + added_object_ids = bindings_dirty(simulation.model) && + !isnothing(simulation.model.binding_dirty_objects) ? + copy(simulation.model.binding_dirty_objects) : nothing + _refresh_simulation_runtime!(simulation) + _refresh_output_request_targets!(simulation, added_object_ids) + return simulation +end + +function _initial_output_request_targets(model, compiled, output_requests) + targets = Dict{Symbol,Tuple{Symbol,Dict{ObjectId,Any}}}() + for request in output_requests + application = _model_request_application(model, compiled, request) + object_ids = resolve_object_ids( + model, + request.selector; + context=request.context, + ) + targets[request.name] = ( + application.id, + Dict(id => _model_object(model, id).scale for id in object_ids), + ) + end + return targets +end + +function _refresh_output_request_targets!(simulation::Simulation, added_object_ids=nothing) + for request in simulation.output_requests + _, object_scales = simulation.output_request_targets[request.name] + matched_ids = if isnothing(added_object_ids) + resolve_object_ids( + simulation.model, + _selector_as_many(request.selector); + context=request.context, + ) + else + ObjectId[ + object_id for object_id in added_object_ids + if _selector_matches_object_id( + simulation.model, + request.selector, + object_id; + context=request.context, + ) + ] + end + match_count = isnothing(added_object_ids) ? + length(matched_ids) : length(object_scales) + length(matched_ids) + if request.selector isa Union{One,OptionalOne} && match_count > 1 + error( + "Output request `$(request.name)` expected at most one current object for selector ", + "`$(request.selector)`, got $([id.value for id in matched_ids]).", + ) + end + for object_id in matched_ids + object_scales[object_id] = _model_object( + simulation.model, + object_id, + ).scale + end + end + return simulation +end + +""" + run!(model; steps=1, constants=Constants(), outputs=:none) + +Run a fresh simulation timeline while mutating object status in `model`. +Choose `outputs=:none`, `outputs=:all`, one [`OutputRequest`](@ref), or a +vector of requests. Use [`continue!`](@ref) on the returned +[`Simulation`](@ref) to advance without resetting time. +""" +function run!( + model::CompositeModel; + steps::Integer=1, + constants=PlantMeteo.Constants(), + outputs=_UNSPECIFIED_SCENE_OUTPUTS, + tracked_outputs=_UNSPECIFIED_SCENE_OUTPUTS, +) + compiled = refresh_bindings!(model) + env_bindings = refresh_environment_bindings!(model, compiled) + empty!(env_bindings.sample_cache) + execution_plan = compile_model_execution_plan(compiled, env_bindings) + output_requests, retain_all = _model_output_selection(outputs, tracked_outputs) + output_retention = compile_model_output_retention( + compiled, + output_requests; + retain_all=retain_all, + ) + temporal_streams = Dict{Tuple{Symbol,ObjectId,Symbol},Any}() + output_request_targets = _initial_output_request_targets( + model, + compiled, + output_requests, + ) + simulation = Simulation( + model, + compiled, + env_bindings, + execution_plan, + output_retention, + temporal_streams, + output_requests, + output_request_targets, + 0, + constants, + ) + return _continue_scene!(simulation, steps) +end + +""" + continue!(simulation; steps=1) + +Advance an existing [`Simulation`](@ref) without resetting its timeline, +retained streams, temporal dependency history, or environment position. +""" +continue!(simulation::Simulation; steps::Integer=1) = + _continue_scene!(simulation, steps) + +""" + step!(simulation) + +Advance an existing [`Simulation`](@ref) by one timestep. +""" +step!(simulation::Simulation) = continue!(simulation; steps=1) + +function _model_output_rows(sim::Simulation, filter_object=nothing, filter_var=nothing) + rows = NamedTuple[] + for ((application_id, object_id, variable), samples) in sort!( + collect(sim.temporal_streams); + by=pair -> (string(first(pair)[1]), string(first(pair)[2].value), string(first(pair)[3])), + ) + isnothing(filter_object) || object_id == ObjectId(filter_object) || continue + isnothing(filter_var) || variable == Symbol(filter_var) || continue + for (time, value) in samples + push!( + rows, + ( + timestep=Int(round(time)), + time=time, + application_id=application_id, + object_id=object_id.value, + variable=variable, + value=value, + ), + ) + end + end + sort!(rows; by=row -> (row.timestep, string(row.object_id), string(row.variable))) + return rows +end + +function _materialize_model_output_rows(rows, sink) + isnothing(sink) && return rows + sink === DataFrames.DataFrame && return DataFrames.DataFrame(rows) + return sink(rows) +end + +function _model_request_application(model::CompositeModel, compiled::CompiledCompositeModel, request) + requested_ids = Set(resolve_object_ids( + model, + request.selector; + context=request.context, + )) + declared_scale = _selector_declared_scale(request.selector) + candidates = CompiledModelApplication[] + for application in compiled.applications + request.var in keys(outputs_(application.spec)) || continue + isnothing(request.process) || application.process == request.process || continue + isnothing(request.application) || + application.id == request.application || + application.name == request.application || + continue + target_match = any(id -> id in requested_ids, application.target_ids) + scale_match = !isnothing(declared_scale) && + _model_application_matches_scale(model, application, declared_scale) + (target_match || scale_match) || continue + if isnothing(request.process) && + isnothing(request.application) && + _publish_mode_for_output(application.spec, request.var) == :stream_only + continue + end + push!(candidates, application) + end + if isempty(candidates) + error( + "No model output publisher found for selector `$(request.selector)` and variable `$(request.var)`", + isnothing(request.application) ? + (isnothing(request.process) ? "." : " from process `$(request.process)`.") : + " from application `$(request.application)`.", + ) + elseif length(candidates) > 1 + error( + "Ambiguous model output publishers for selector `$(request.selector)` and variable `$(request.var)`: ", + join((application.id for application in candidates), ", "), + ". Provide `application=` or make one publisher canonical.", + ) + end + return only(candidates) +end + +function _model_request_application(sim::Simulation, request) + return _model_request_application(sim.model, sim.compiled, request) +end + +function _selector_declared_scale(selector::AbstractObjectMultiplicity) + selector_criteria = criteria(selector) + scale = _criteria_get(selector_criteria, :scale, nothing) + !isnothing(scale) && return scale + for positional_selector in _criteria_get(selector_criteria, :selectors, ()) + positional_selector isa Scale && return positional_selector.scale + end + return nothing +end + +function _model_application_matches_scale( + model::CompositeModel, + application::CompiledModelApplication, + scale::Symbol, +) + declared_scale = _selector_declared_scale(application.applies_to) + declared_scale == scale && return true + for object_id in application.target_ids + object = get(model.registry.objects, object_id, nothing) + !isnothing(object) && object.scale == scale && return true + end + return false +end + +function _model_stream_object_matches_scale( + model::CompositeModel, + application::CompiledModelApplication, + object_id::ObjectId, + scale::Symbol, +) + object = get(model.registry.objects, object_id, nothing) + !isnothing(object) && return object.scale == scale + declared_scale = _selector_declared_scale(application.applies_to) + return isnothing(declared_scale) || declared_scale == scale +end + +function _model_request_clock(request, timeline) + isnothing(request.clock) && return ClockSpec(1.0, 0.0) + clock = _clock_from_spec_timestep(request.clock, timeline) + isnothing(clock) && error( + "Unsupported clock specification `$(typeof(request.clock))` in ", + "OutputRequest `$(request.name)`.", + ) + return clock +end + +function _model_requested_value(samples, time, t_start, policy, timeline) + if policy isa HoldLast + value = _model_latest_sample(samples, time) + return isnothing(value) ? missing : value + elseif policy isa Interpolate + value = _model_interpolated_sample(samples, time, policy) + return isnothing(value) ? missing : value + elseif policy isa Union{Integrate,Aggregate} + values, durations = _model_window_segments( + samples, + t_start, + time, + timeline.base_step_seconds, + ) + isempty(values) && return missing + return _model_window_reduce(values, durations, policy) + end + error("Unsupported model output request policy `$(typeof(policy))`.") +end + +function _model_requested_output_rows( + sim::Simulation, + request, +) + application_id, requested_objects = sim.output_request_targets[request.name] + requested_ids = keys(requested_objects) + application = _compiled_application_by_id(sim.compiled, application_id) + declared_scale = _selector_declared_scale(request.selector) + timeline = _model_timeline(sim.model) + clock = _model_request_clock(request, timeline) + source_rows = [ + (object_id=object_id, samples=samples) + for ((application_id, object_id, variable), samples) in sim.temporal_streams + if application_id == application.id && + variable == request.var && + (object_id in requested_ids || + (!isnothing(declared_scale) && + _model_stream_object_matches_scale(sim.model, application, object_id, declared_scale))) + ] + isempty(source_rows) && return NamedTuple[] + nonempty_source_rows = [row for row in source_rows if !isempty(row.samples)] + isempty(nonempty_source_rows) && return NamedTuple[] + max_time = maximum(last(row.samples)[1] for row in nonempty_source_rows) + rows = NamedTuple[] + for time in 1:Int(floor(max_time)) + _should_run_at_time(clock, float(time)) || continue + t_start = float(time) - float(clock.dt) + 1.0 + for row in sort!(nonempty_source_rows; by=row -> string(row.object_id.value)) + first_sample_time = first(row.samples)[1] + last_sample_time = last(row.samples)[1] + first_sample_time <= float(time) <= last_sample_time || continue + push!( + rows, + ( + timestep=time, + time=float(time), + scale=isnothing(declared_scale) ? + get(requested_objects, row.object_id, missing) : + declared_scale, + process=application.process, + application_id=application.id, + variable=request.var, + object_id=row.object_id.value, + value=_model_requested_value( + row.samples, + float(time), + t_start, + request.policy, + timeline, + ), + ), + ) + end + end + return rows +end + +function _collect_model_requested_outputs(sim::Simulation, sink) + outputs = Dict{Symbol,Any}() + for request in sim.output_requests + haskey(outputs, request.name) && error( + "Duplicate output request name `$(request.name)`. Request names must be unique." + ) + outputs[request.name] = _materialize_model_output_rows( + _model_requested_output_rows(sim, request), + sink, + ) + end + return outputs +end + +function collect_outputs(sim::Simulation; sink=DataFrames.DataFrame) + isempty(sim.output_requests) || return _collect_model_requested_outputs(sim, sink) + return _materialize_model_output_rows(_model_output_rows(sim), sink) +end + +function collect_outputs(sim::Simulation, name::Symbol; sink=DataFrames.DataFrame) + matches = [request for request in sim.output_requests if request.name == name] + isempty(matches) && error( + "No model output request named `$(name)`. Available request names are ", + isempty(sim.output_requests) ? "none." : join((request.name for request in sim.output_requests), ", "), + ) + length(matches) == 1 || error( + "Duplicate model output request name `$(name)`. Request names must be unique." + ) + request = only(matches) + return _materialize_model_output_rows( + _model_requested_output_rows(sim, request), + sink, + ) +end + +function collect_outputs(sim::Simulation, object_id, variable::Symbol; sink=DataFrames.DataFrame) + return _materialize_model_output_rows(_model_output_rows(sim, object_id, variable), sink) +end + +function explain_outputs(sim::Simulation) + return [ + ( + application_id=application_id, + object_id=object_id.value, + variable=variable, + nsamples=length(samples), + first_time=isempty(samples) ? nothing : first(samples)[1], + last_time=isempty(samples) ? nothing : last(samples)[1], + value_type=isempty(samples) ? Union{} : typeof(last(samples)[2]), + ) + for ((application_id, object_id, variable), samples) in sort!( + collect(sim.temporal_streams); + by=pair -> (string(first(pair)[1]), string(first(pair)[2].value), string(first(pair)[3])), + ) + ] +end diff --git a/src/composite_model/scenario_dsl.jl b/src/composite_model/scenario_dsl.jl new file mode 100644 index 000000000..90a810957 --- /dev/null +++ b/src/composite_model/scenario_dsl.jl @@ -0,0 +1,130 @@ +struct ObjectAddress{SC,K,SP,S,N,P,V,R,M} + scope::SC + kind::K + species::SP + scale::S + name::N + process::P + var::V + relation::R + multiplicity::M +end + +function ObjectAddress(selector::AbstractObjectMultiplicity) + c = criteria(selector) + scope = _criteria_scope(c) + kind = _criteria_value(c, :kind, Kind) + species = _criteria_value(c, :species, Species) + scale = _criteria_value(c, :scale, Scale) + name = haskey(c, :name) ? c.name : nothing + process = haskey(c, :process) ? c.process : nothing + var = haskey(c, :var) ? c.var : nothing + relation = _criteria_value(c, :relation, Relation) + return ObjectAddress(scope, kind, species, scale, name, process, var, relation, multiplicity(selector)) +end + +object_address(selector::AbstractObjectMultiplicity) = ObjectAddress(selector) + +struct Input{S} + selector::S +end +Input(; kwargs...) = Input(One(; kwargs...)) + +struct Call{S} + selector::S +end +Call(; kwargs...) = Call(One(; kwargs...)) + +struct EnvironmentConfig{C} + config::C +end + +_normalize_application_name(name) = isnothing(name) ? nothing : Symbol(name) + +function _normalize_application_bindings(bindings::NamedTuple) + return bindings +end + +function _normalize_application_bindings(bindings::Tuple) + pairs = Pair{Symbol,Any}[] + for binding in bindings + binding isa Pair || error( + "Expected `var => selector` pairs in `Inputs(...)` or `Calls(...)`, got `$(typeof(binding))`." + ) + key = first(binding) + selector = last(binding) + if key isa PreviousTimeStep + selector isa AbstractObjectMultiplicity || error( + "A `PreviousTimeStep(...)` input must map to `One(...)`, ", + "`OptionalOne(...)`, or `Many(...)`." + ) + push!( + pairs, + key.variable => _selector_with_previous_timestep(selector, key), + ) + else + key isa Union{Symbol,AbstractString} || error( + "Binding names in `Inputs(...)` and `Calls(...)` must be symbols, ", + "strings, or `PreviousTimeStep(:input)` markers." + ) + push!(pairs, Symbol(key) => selector) + end + end + return (; pairs...) +end + +function _normalize_application_bindings(binding::Pair) + return _normalize_application_bindings((binding,)) +end + +function _normalize_application_bindings(bindings) + error( + "Unsupported binding declaration `$(bindings)` of type `$(typeof(bindings))`. ", + "Use pairs such as `:x => Many(...)` or keyword arguments." + ) +end + +function _model_default_value_inputs(model) + defaults = Pair{Symbol,Any}[] + for (dep_name, selector) in pairs(dep(model)) + selector isa Input || continue + push!(defaults, Symbol(dep_name) => selector.selector) + end + return (; defaults...) +end + +function _model_default_model_calls(model) + defaults = Pair{Symbol,Any}[] + for (dep_name, selector) in pairs(dep(model)) + selector isa Call || continue + push!(defaults, Symbol(dep_name) => selector.selector) + end + return (; defaults...) +end + +function _merge_value_inputs(defaults::NamedTuple, explicit::NamedTuple) + return (; pairs(defaults)..., pairs(explicit)...) +end + +function _binding_origins(defaults::NamedTuple, explicit::NamedTuple) + origins = Pair{Symbol,Symbol}[] + for name in keys(defaults) + push!(origins, Symbol(name) => :model_default) + end + for name in keys(explicit) + push!(origins, Symbol(name) => :model_spec) + end + return (; origins...) +end + +function _normalize_binding_origins(origins::NamedTuple, bindings::NamedTuple) + normalized = Pair{Symbol,Symbol}[] + for name in keys(bindings) + origin = haskey(origins, name) ? Symbol(getproperty(origins, name)) : :model_spec + origin in (:model_default, :model_spec) || error( + "Unsupported binding origin `$(origin)` for `$(name)`." + ) + push!(normalized, Symbol(name) => origin) + end + return (; normalized...) +end diff --git a/src/composite_model/selectors.jl b/src/composite_model/selectors.jl new file mode 100644 index 000000000..a31c7901e --- /dev/null +++ b/src/composite_model/selectors.jl @@ -0,0 +1,916 @@ +struct SceneScope <: AbstractObjectSelector end +struct Self <: AbstractObjectSelector end +struct Subtree <: AbstractObjectSelector end +struct SelfPlant <: AbstractObjectSelector end + +struct Ancestor <: AbstractObjectSelector + scale::Union{Nothing,Symbol} +end +Ancestor(; scale=nothing) = Ancestor(_maybe_symbol(scale)) + +struct Scope <: AbstractObjectSelector + name::Symbol +end +Scope(name::Union{Symbol,AbstractString}) = Scope(Symbol(name)) + +struct Kind <: AbstractObjectSelector + kind::Symbol +end +Kind(kind::Union{Symbol,AbstractString}) = Kind(Symbol(kind)) + +struct Species <: AbstractObjectSelector + species::Symbol +end +Species(species::Union{Symbol,AbstractString}) = Species(Symbol(species)) + +struct Scale <: AbstractObjectSelector + scale::Symbol +end +Scale(scale::Union{Symbol,AbstractString}) = Scale(Symbol(scale)) + +struct Relation <: AbstractObjectSelector + relation::Symbol + function Relation(relation::Symbol) + relation in _OBJECT_RELATIONS || error( + "Unsupported object relation `$(relation)`. Supported relations are $(_OBJECT_RELATIONS)." + ) + return new(relation) + end +end +const _OBJECT_RELATIONS = (:self, :parent, :children, :ancestors, :descendants, :siblings) +Relation(relation::AbstractString) = Relation(Symbol(relation)) + +_maybe_symbol(x) = isnothing(x) ? nothing : Symbol(x) + +const _OBJECT_ADDRESS_SYMBOL_FIELDS = (:kind, :species, :scale, :name, :process, :var, :relation, :application) + +function _maybe_symbol_collection(value) + value isa Tuple && return Tuple(_maybe_symbol(item) for item in value) + value isa AbstractVector && return Tuple(_maybe_symbol(item) for item in value) + return _maybe_symbol(value) +end + +function _normalize_object_selector_value(key::Symbol, value) + key in _OBJECT_ADDRESS_SYMBOL_FIELDS && return _maybe_symbol_collection(value) + return value +end + +function _object_matches_selector_value(object_value, requested) + isnothing(requested) && return true + requested isa Tuple && return object_value in requested + return object_value == requested +end + +function _normalize_selector_kwargs(kwargs) + return NamedTuple{keys(kwargs)}( + Tuple(_normalize_object_selector_value(k, v) for (k, v) in pairs(kwargs)) + ) +end + +function _normalize_selector_criteria(args::Tuple; kwargs...) + selectors = Tuple(args) + all(selector -> selector isa AbstractObjectSelector, selectors) || error( + "Object selector positional arguments must be selector objects such as `Kind(:plant)` or `Scale(:Leaf)`." + ) + normalized_kwargs = _normalize_selector_kwargs((; kwargs...)) + return (; selectors=selectors, normalized_kwargs...) +end + +struct One{C<:NamedTuple} <: AbstractObjectMultiplicity + criteria::C +end +struct OptionalOne{C<:NamedTuple} <: AbstractObjectMultiplicity + criteria::C +end +struct Many{C<:NamedTuple} <: AbstractObjectMultiplicity + criteria::C +end + +One(args...; kwargs...) = One(_normalize_selector_criteria(args; kwargs...)) +OptionalOne(args...; kwargs...) = OptionalOne(_normalize_selector_criteria(args; kwargs...)) +Many(args...; kwargs...) = Many(_normalize_selector_criteria(args; kwargs...)) + +criteria(selector::AbstractObjectMultiplicity) = selector.criteria +multiplicity(::One) = :one +multiplicity(::OptionalOne) = :optional_one +multiplicity(::Many) = :many + +_rebuild_selector(::One, criteria) = One{typeof(criteria)}(criteria) +_rebuild_selector(::OptionalOne, criteria) = OptionalOne{typeof(criteria)}(criteria) +_rebuild_selector(::Many, criteria) = Many{typeof(criteria)}(criteria) +_selector_as_many(selector::AbstractObjectMultiplicity) = + Many{typeof(criteria(selector))}(criteria(selector)) + +function _selector_with_scope(selector::AbstractObjectMultiplicity, scope) + selector_criteria = criteria(selector) + haskey(selector_criteria, :within) && return selector + return _rebuild_selector(selector, merge(selector_criteria, (; within=scope))) +end + +function _selector_with_application_prefix( + selector::AbstractObjectMultiplicity, + instance_name::Symbol, + template_application_names::Set{Symbol}, +) + selector_criteria = criteria(selector) + haskey(selector_criteria, :application) || return selector + application = selector_criteria.application + isnothing(application) && return selector + application in template_application_names || return selector + mounted_name = Symbol(instance_name, "__", application) + return _rebuild_selector(selector, merge(selector_criteria, (; application=mounted_name))) +end + +function _selector_with_previous_timestep( + selector::AbstractObjectMultiplicity, + previous::PreviousTimeStep, +) + return _rebuild_selector( + selector, + merge(criteria(selector), (; policy=previous)), + ) +end + +function _mounted_application_name(spec, index::Int) + name = application_name(spec) + return isnothing(name) ? process(spec) : name +end + +function _instance_override_matches(spec, key::Symbol) + name = application_name(spec) + return key == process(spec) || (!isnothing(name) && key == name) +end + +function _instance_override_models(instance::ObjectInstance, specs) + selected = Dict{Int,AbstractModel}() + for (key, replacement) in pairs(instance.overrides) + replacement isa AbstractModel || error( + "Override `$(key)` for instance `$(instance.name)` must be an `AbstractModel`, got `$(typeof(replacement))`." + ) + matches = findall(spec -> _instance_override_matches(spec, Symbol(key)), specs) + isempty(matches) && error( + "Override `$(key)` for instance `$(instance.name)` does not match a template application name or process." + ) + length(matches) == 1 || error( + "Override `$(key)` for instance `$(instance.name)` matches several template applications; use a unique application name." + ) + index = only(matches) + haskey(selected, index) && error( + "Several overrides target template application `$(_mounted_application_name(specs[index], index))` in instance `$(instance.name)`." + ) + _validate_model_override_contract!( + model_(specs[index]), + replacement; + description="Override `$(key)` for instance `$(instance.name)`", + ) + selected[index] = replacement + end + return selected +end + +function _model_contract(model) + return ( + process=process(model), + inputs=Tuple(Symbol.(keys(inputs_(model)))), + outputs=Tuple(Symbol.(keys(outputs_(model)))), + meteo_inputs=Tuple(Symbol.(keys(meteo_inputs_(model)))), + meteo_outputs=Tuple(Symbol.(keys(meteo_outputs_(model)))), + ) +end + +function _validate_model_override_contract!(base, replacement; description) + base_contract = _model_contract(base) + replacement_contract = _model_contract(replacement) + base_contract == replacement_contract && return nothing + error( + "$(description) has an incompatible model contract. Expected ", + "`$(base_contract)`, got `$(replacement_contract)`. Object and instance ", + "overrides may change parameters or implementation, but not process or declared variables." + ) +end + +function _object_override_matches(spec, override::Override) + process_match = isnothing(override.process) || process(spec) == override.process + name = application_name(spec) + application_match = isnothing(override.application) || + (!isnothing(name) && name == override.application) + return process_match && application_match +end + +function _object_override_models(instance::ObjectInstance, specs, instance_ids) + entries = Dict{Int,Vector{Pair{ObjectId,AbstractModel}}}() + valid_ids = Set(instance_ids) + for override in instance.object_overrides + override.object in valid_ids || error( + "Object override for `$(override.object.value)` does not belong to instance `$(instance.name)`." + ) + matches = findall(spec -> _object_override_matches(spec, override), specs) + isempty(matches) && error( + "Object override for `$(override.object.value)` in instance `$(instance.name)` ", + "does not match a template application." + ) + length(matches) == 1 || error( + "Object override for `$(override.object.value)` in instance `$(instance.name)` ", + "matches several template applications; add `application=...`." + ) + index = only(matches) + object_models = get!(entries, index, Pair{ObjectId,AbstractModel}[]) + any(entry -> first(entry) == override.object, object_models) && error( + "Several object overrides target application `$(_mounted_application_name(specs[index], index))` ", + "on object `$(override.object.value)` in instance `$(instance.name)`." + ) + _validate_model_override_contract!( + model_(specs[index]), + override.model; + description="Object override for `$(override.object.value)` in instance `$(instance.name)`", + ) + push!(object_models, override.object => override.model) + end + selected = Dict{Int,Any}() + for (index, object_models) in entries + selected[index] = _typed_object_model_dict(object_models) + end + return selected +end + +function _typed_object_model_dict(entries) + isempty(entries) && return Dict{ObjectId,AbstractModel}() + model_type = typeof(last(first(entries))) + if all(entry -> typeof(last(entry)) == model_type, entries) + models = Dict{ObjectId,model_type}() + for (object_id, model) in entries + models[object_id] = model + end + return models + end + return Dict{ObjectId,AbstractModel}(entries) +end + +function _map_selector_bindings(bindings::NamedTuple, f) + mapped = Pair{Symbol,Any}[] + for (name, selector) in pairs(bindings) + push!(mapped, Symbol(name) => (selector isa AbstractObjectMultiplicity ? f(selector) : selector)) + end + return (; mapped...) +end + +function _mount_object_instance_applications(instance::ObjectInstance, instance_ids) + specs = Tuple(as_model_spec(application) for application in instance.template.applications) + base_names = Set(_mounted_application_name(spec, index) for (index, spec) in pairs(specs)) + instance_overrides = _instance_override_models(instance, specs) + object_overrides = _object_override_models(instance, specs, instance_ids) + mounted = Any[] + for (index, spec) in pairs(specs) + base_name = _mounted_application_name(spec, index) + mounted_name = Symbol(instance.name, "__", base_name) + target = applies_to(spec) + isnothing(target) && error( + "Template application `$(base_name)` has no `AppliesTo(...)` selector." + ) + mounted_target = _selector_with_scope(target, Scope(instance.name)) + prefix_application = selector -> _selector_with_application_prefix( + selector, + instance.name, + base_names, + ) + mounted_inputs = _map_selector_bindings(value_inputs(spec), prefix_application) + mounted_calls = _map_selector_bindings(model_calls(spec), prefix_application) + mounted_model = get(instance_overrides, index, model_(spec)) + if haskey(object_overrides, index) + object_models = object_overrides[index] + for replacement in values(object_models) + _validate_model_override_contract!( + mounted_model, + replacement; + description="Object override for template application `$(base_name)`", + ) + end + mounted_model = ObjectModelOverrides(mounted_model, object_models) + end + push!( + mounted, + ModelSpec( + spec; + model=mounted_model, + name=mounted_name, + applies_to=mounted_target, + inputs=mounted_inputs, + calls=mounted_calls, + ), + ) + end + return Tuple(mounted) +end + +function _mount_object_instance_applications(instances, instance_ids) + mounted = Any[] + for instance in instances + append!( + mounted, + _mount_object_instance_applications(instance, instance_ids[instance.name]), + ) + end + return Tuple(mounted) +end + +function _object_id_isless(left::ObjectId, right::ObjectId) + left_value = left.value + right_value = right.value + if typeof(left_value) === typeof(right_value) && + hasmethod(isless, Tuple{typeof(left_value),typeof(right_value)}) + return isless(left_value, right_value) + end + return isless(string(left_value), string(right_value)) +end + +_sort_object_ids!(ids) = sort!(ids; lt=_object_id_isless) + +function _object_id_from_context(context) + isnothing(context) && return nothing + context isa Object && return context.id + return ObjectId(context) +end + +function _descendant_ids(model::CompositeModel, root_id::ObjectId) + ids = ObjectId[root_id] + object = _model_object(model, root_id) + for child_id in object.children + append!(ids, _descendant_ids(model, child_id)) + end + return ids +end + +function _ancestor_id( + model::CompositeModel, + current_id::ObjectId; + scale=nothing, + kind=nothing, + include_self::Bool=true, +) + id = if include_self + current_id + else + parent = _model_object(model, current_id).parent + isnothing(parent) && return nothing + parent + end + while true + object = _model_object(model, id) + scale_match = isnothing(scale) || object.scale == Symbol(scale) + kind_match = isnothing(kind) || object.kind == Symbol(kind) + scale_match && kind_match && return id + isnothing(object.parent) && return nothing + id = object.parent + end +end + +function _ancestor_ids(model::CompositeModel, current_id::ObjectId) + ids = ObjectId[] + parent_id = _model_object(model, current_id).parent + while !isnothing(parent_id) + push!(ids, parent_id) + parent_id = _model_object(model, parent_id).parent + end + return ids +end + +function _relation_object_ids(model::CompositeModel, relation::Symbol, context) + current_id = _object_id_from_context(context) + isnothing(current_id) && error( + "`Relation(:$(relation))` selectors require a current object context." + ) + object = _model_object(model, current_id) + relation == :self && return ObjectId[current_id] + relation == :parent && return isnothing(object.parent) ? ObjectId[] : ObjectId[object.parent] + relation == :children && return copy(object.children) + relation == :ancestors && return _ancestor_ids(model, current_id) + relation == :descendants && return _descendant_ids(model, current_id)[2:end] + if relation == :siblings + isnothing(object.parent) && return ObjectId[] + return ObjectId[ + sibling_id for sibling_id in _model_object(model, object.parent).children + if sibling_id != current_id + ] + end + error("Unsupported object relation `$(relation)`.") +end + +function _selector_scope_from_positional(selectors) + scopes = filter( + selector -> selector isa Union{SceneScope,Self,Subtree,SelfPlant,Ancestor,Scope}, + selectors, + ) + isempty(scopes) && return nothing + length(scopes) == 1 || error("Only one scope selector can be used in one object selector.") + return only(scopes) +end + +function _selector_value_from_positional(selectors, ::Type{Kind}) + values = [selector.kind for selector in selectors if selector isa Kind] + isempty(values) && return nothing + length(unique(values)) == 1 || error("Conflicting `Kind(...)` selector values: $(values).") + return only(unique(values)) +end + +function _selector_value_from_positional(selectors, ::Type{Species}) + values = [selector.species for selector in selectors if selector isa Species] + isempty(values) && return nothing + length(unique(values)) == 1 || error("Conflicting `Species(...)` selector values: $(values).") + return only(unique(values)) +end + +function _selector_value_from_positional(selectors, ::Type{Scale}) + values = [selector.scale for selector in selectors if selector isa Scale] + isempty(values) && return nothing + length(unique(values)) == 1 || error("Conflicting `Scale(...)` selector values: $(values).") + return only(unique(values)) +end + +function _selector_value_from_positional(selectors, ::Type{Relation}) + values = [selector.relation for selector in selectors if selector isa Relation] + isempty(values) && return nothing + length(unique(values)) == 1 || error("Conflicting `Relation(...)` selector values: $(values).") + return only(unique(values)) +end + +function _criteria_value(criteria, key::Symbol, selector_type) + positional = _selector_value_from_positional(criteria.selectors, selector_type) + keyword = haskey(criteria, key) ? getproperty(criteria, key) : nothing + if !isnothing(positional) && !isnothing(keyword) && positional != keyword + error("Conflicting selector values for `$(key)`: `$(positional)` and `$(keyword)`.") + end + return isnothing(keyword) ? positional : keyword +end + +function _criteria_scope(criteria) + positional = _selector_scope_from_positional(criteria.selectors) + keyword = haskey(criteria, :within) ? criteria.within : nothing + if !isnothing(positional) && !isnothing(keyword) && typeof(positional) != typeof(keyword) + error("Conflicting scope selectors: `$(positional)` and `$(keyword)`.") + end + return isnothing(keyword) ? positional : keyword +end + +function _scope_object_ids(model::CompositeModel, scope, context) + if isnothing(scope) || scope isa SceneScope + return ObjectId[keys(model.registry.objects)...] + end + + current_id = _object_id_from_context(context) + if scope isa Self + isnothing(current_id) && error("`Self()` selectors require a current object context.") + return ObjectId[current_id] + elseif scope isa Subtree + isnothing(current_id) && error("`Subtree()` selectors require a current object context.") + return _descendant_ids(model, current_id) + elseif scope isa SelfPlant + isnothing(current_id) && error("`SelfPlant()` selectors require a current object context.") + plant_id = _ancestor_id(model, current_id; scale=:Plant) + isnothing(plant_id) && error("No `scale=:Plant` ancestor found for object `$(current_id.value)`.") + return _descendant_ids(model, plant_id) + elseif scope isa Ancestor + isnothing(current_id) && error("`Ancestor(...)` selectors require a current object context.") + ancestor_id = _ancestor_id( + model, + current_id; + scale=scope.scale, + include_self=false, + ) + if isnothing(ancestor_id) + error("No matching ancestor found for object `$(current_id.value)` and selector `$(scope)`.") + end + return _descendant_ids(model, ancestor_id) + elseif scope isa Scope + root_id = get(model.registry.by_name, scope.name, nothing) + if isnothing(root_id) + candidate = ObjectId(scope.name) + root_id = haskey(model.registry.objects, candidate) ? candidate : nothing + end + if isnothing(root_id) + available = sort!(unique(Symbol[ + keys(model.registry.by_name)..., + (id.value for id in keys(model.registry.objects))..., + ]); by=string) + suggestions = _near_symbol_matches(scope.name, available) + error( + "No named scope or object `$(scope.name)` found in the model registry. ", + "available=$(available), suggestions=$(suggestions)." + ) + end + return _descendant_ids(model, root_id) + end + + error("Unsupported object scope selector `$(scope)` of type `$(typeof(scope))`.") +end + +function _registry_selector_ids(index, requested) + isnothing(requested) && return nothing + requested_values = requested isa Tuple ? requested : (requested,) + ids = Set{ObjectId}() + for value in requested_values + union!(ids, get(index, Symbol(value), Set{ObjectId}())) + end + return ids +end + +function _indexed_object_ids(model::CompositeModel; scale=nothing, kind=nothing, species=nothing, name=nothing) + candidate_sets = Set{ObjectId}[] + for ids in ( + _registry_selector_ids(model.registry.by_scale, scale), + _registry_selector_ids(model.registry.by_kind, kind), + _registry_selector_ids(model.registry.by_species, species), + ) + isnothing(ids) || push!(candidate_sets, ids) + end + if !isnothing(name) + names = name isa Tuple ? name : (name,) + push!( + candidate_sets, + Set{ObjectId}( + id for candidate_name in names + for id in (get(model.registry.by_name, Symbol(candidate_name), nothing),) + if !isnothing(id) + ), + ) + end + isempty(candidate_sets) && return nothing + sort!(candidate_sets; by=length) + ids = copy(first(candidate_sets)) + for candidates in Iterators.drop(candidate_sets, 1) + intersect!(ids, candidates) + end + return ObjectId[ids...] +end + +function _object_is_in_subtree(model::CompositeModel, object_id::ObjectId, root_id::ObjectId) + current_id = object_id + while true + current_id == root_id && return true + parent_id = _model_object(model, current_id).parent + isnothing(parent_id) && return false + current_id = parent_id + end +end + +function _scope_contains_object(model::CompositeModel, scope, context, object_id::ObjectId) + (isnothing(scope) || scope isa SceneScope) && return true + return if scope isa Self + current_id = _object_id_from_context(context) + isnothing(current_id) && error("`Self()` selectors require a current object context.") + object_id == current_id + elseif scope isa Subtree + current_id = _object_id_from_context(context) + isnothing(current_id) && error("`Subtree()` selectors require a current object context.") + _object_is_in_subtree(model, object_id, current_id) + elseif scope isa SelfPlant + current_id = _object_id_from_context(context) + isnothing(current_id) && error("`SelfPlant()` selectors require a current object context.") + plant_id = _ancestor_id(model, current_id; scale=:Plant) + isnothing(plant_id) && error("No `scale=:Plant` ancestor found for object `$(current_id.value)`.") + _object_is_in_subtree(model, object_id, plant_id) + elseif scope isa Ancestor + current_id = _object_id_from_context(context) + isnothing(current_id) && error("`Ancestor(...)` selectors require a current object context.") + ancestor_id = _ancestor_id(model, current_id; scale=scope.scale, include_self=false) + isnothing(ancestor_id) && error( + "No matching ancestor found for object `$(current_id.value)` and selector `$(scope)`." + ) + _object_is_in_subtree(model, object_id, ancestor_id) + else + object_id in Set(_scope_object_ids(model, scope, context)) + end +end + +function _symbol_edit_distance(left::Symbol, right::Symbol) + a = collect(string(left)) + b = collect(string(right)) + previous = collect(0:length(b)) + current = similar(previous) + for i in eachindex(a) + current[1] = i + for j in eachindex(b) + substitution = previous[j] + (a[i] == b[j] ? 0 : 1) + current[j + 1] = min(current[j] + 1, previous[j + 1] + 1, substitution) + end + previous, current = current, previous + end + return previous[end] +end + +function _near_symbol_matches(requested, available) + isnothing(requested) && return Symbol[] + requested_symbol = Symbol(requested) + threshold = max(2, cld(length(string(requested_symbol)), 3)) + ranked = Tuple{Int,Symbol}[ + (_symbol_edit_distance(requested_symbol, candidate), candidate) + for candidate in available + ] + filter!(pair -> first(pair) <= threshold, ranked) + sort!(ranked; by=pair -> (first(pair), string(last(pair)))) + return Symbol[last(pair) for pair in Iterators.take(ranked, 3)] +end + +function _available_selector_labels(model::CompositeModel, candidate_ids) + objects = (_model_object(model, id) for id in candidate_ids) + scales = Symbol[] + kinds = Symbol[] + species = Symbol[] + names = Symbol[] + for object in objects + isnothing(object.scale) || push!(scales, object.scale) + isnothing(object.kind) || push!(kinds, object.kind) + isnothing(object.species) || push!(species, object.species) + isnothing(object.name) || push!(names, object.name) + end + return ( + scales=sort!(unique!(scales); by=string), + kinds=sort!(unique!(kinds); by=string), + species=sort!(unique!(species); by=string), + names=sort!(unique!(names); by=string), + ) +end + +function _selector_resolution_error( + model::CompositeModel, + selector, + candidate_ids, + matched_ids; + context=nothing, + scale=nothing, + kind=nothing, + species=nothing, + name=nothing, +) + available = _available_selector_labels(model, candidate_ids) + suggestions = ( + scale=_near_symbol_matches(scale, available.scales), + kind=_near_symbol_matches(kind, available.kinds), + species=_near_symbol_matches(species, available.species), + name=_near_symbol_matches(name, available.names), + ) + expected = selector isa One ? "exactly one" : "zero or one" + context_id = _object_id_from_context(context) + error( + "Expected $(expected) object for selector `$(selector)`, got $(length(matched_ids)). ", + "context=$(isnothing(context_id) ? nothing : context_id.value), ", + "matched_ids=$([id.value for id in matched_ids]), ", + "requested=(scale=$(scale), kind=$(kind), species=$(species), name=$(name)), ", + "available=$(available), suggestions=$(suggestions)." + ) +end + +function _matches_object_criteria(object::Object; scale=nothing, kind=nothing, species=nothing, name=nothing) + _object_matches_selector_value(object.scale, scale) || return false + _object_matches_selector_value(object.kind, kind) || return false + _object_matches_selector_value(object.species, species) || return false + _object_matches_selector_value(object.name, name) || return false + return true +end + +function _selector_matches_object_id( + model::CompositeModel, + selector::AbstractObjectMultiplicity, + object_id::ObjectId; + context=nothing, + default_to_context::Bool=false, + default_scope=nothing, +) + criteria_ = criteria(selector) + relation = _criteria_value(criteria_, :relation, Relation) + explicit_scope = _criteria_scope(criteria_) + scale = _criteria_value(criteria_, :scale, Scale) + kind = _criteria_value(criteria_, :kind, Kind) + species = _criteria_value(criteria_, :species, Species) + name = haskey(criteria_, :name) ? criteria_.name : nothing + if default_to_context && + isnothing(explicit_scope) && + isnothing(relation) && + isnothing(scale) && + isnothing(kind) && + isnothing(species) && + isnothing(name) && + !isnothing(context) + return object_id == _object_id_from_context(context) + end + object = _model_object(model, object_id) + _matches_object_criteria(object; scale=scale, kind=kind, species=species, name=name) || + return false + if isnothing(relation) + scope = isnothing(explicit_scope) ? default_scope : explicit_scope + if scope isa Ancestor && !isnothing(scale) && scale == scope.scale + current_id = _object_id_from_context(context) + isnothing(current_id) && error( + "`Ancestor(...)` selectors require a current object context." + ) + return object_id == _ancestor_id( + model, + current_id; + scale=scope.scale, + include_self=false, + ) + end + return _scope_contains_object(model, scope, context, object_id) + end + object_id in _relation_object_ids(model, relation, context) || return false + return isnothing(explicit_scope) || + _scope_contains_object(model, explicit_scope, context, object_id) +end + +function _selector_matches_any_object_id( + model::CompositeModel, + selector::AbstractObjectMultiplicity, + object_ids; + context=nothing, + default_to_context::Bool=false, + default_scope=nothing, +) + criteria_ = criteria(selector) + relation = _criteria_value(criteria_, :relation, Relation) + explicit_scope = _criteria_scope(criteria_) + scale = _criteria_value(criteria_, :scale, Scale) + kind = _criteria_value(criteria_, :kind, Kind) + species = _criteria_value(criteria_, :species, Species) + name = haskey(criteria_, :name) ? criteria_.name : nothing + if default_to_context && + isnothing(explicit_scope) && + isnothing(relation) && + isnothing(scale) && + isnothing(kind) && + isnothing(species) && + isnothing(name) && + !isnothing(context) + return _object_id_from_context(context) in object_ids + end + related_ids = isnothing(relation) ? nothing : Set(_relation_object_ids(model, relation, context)) + scope = isnothing(explicit_scope) ? default_scope : explicit_scope + if isnothing(relation) && scope isa Ancestor && !isnothing(scale) && scale == scope.scale + current_id = _object_id_from_context(context) + isnothing(current_id) && error( + "`Ancestor(...)` selectors require a current object context." + ) + ancestor_id = _ancestor_id( + model, + current_id; + scale=scope.scale, + include_self=false, + ) + isnothing(ancestor_id) && return false + ancestor_id in object_ids || return false + object = _model_object(model, ancestor_id) + return _matches_object_criteria( + object; + scale=scale, + kind=kind, + species=species, + name=name, + ) + end + for object_id in object_ids + object = _model_object(model, object_id) + _matches_object_criteria(object; scale=scale, kind=kind, species=species, name=name) || + continue + isnothing(related_ids) || object_id in related_ids || continue + _scope_contains_object(model, scope, context, object_id) || continue + return true + end + return false +end + +function resolve_object_ids(model::CompositeModel, selector::AbstractObjectMultiplicity; context=nothing) + return _resolve_object_ids(model, selector; context=context) +end + +function _resolve_object_ids( + model::CompositeModel, + selector::AbstractObjectMultiplicity; + context=nothing, + default_to_context::Bool=false, + default_scope=nothing, +) + criteria_ = criteria(selector) + relation = _criteria_value(criteria_, :relation, Relation) + + explicit_scope = _criteria_scope(criteria_) + scope = if !isnothing(explicit_scope) + explicit_scope + elseif isnothing(relation) + default_scope + else + nothing + end + scale = _criteria_value(criteria_, :scale, Scale) + kind = _criteria_value(criteria_, :kind, Kind) + species = _criteria_value(criteria_, :species, Species) + name = haskey(criteria_, :name) ? criteria_.name : nothing + + if default_to_context && + isnothing(explicit_scope) && + isnothing(relation) && + isnothing(scale) && + isnothing(kind) && + isnothing(species) && + isnothing(name) && + !isnothing(context) + return ObjectId[_object_id_from_context(context)] + end + + indexed_ids = _indexed_object_ids( + model; + scale=scale, + kind=kind, + species=species, + name=name, + ) + candidate_ids = if isnothing(relation) + if scope isa Ancestor && !isnothing(scale) && scale == scope.scale + current_id = _object_id_from_context(context) + isnothing(current_id) && error( + "`Ancestor(...)` selectors require a current object context." + ) + ancestor_id = _ancestor_id( + model, + current_id; + scale=scope.scale, + include_self=false, + ) + isnothing(ancestor_id) ? ObjectId[] : ObjectId[ancestor_id] + elseif isnothing(indexed_ids) + _scope_object_ids(model, scope, context) + elseif isnothing(scope) || scope isa SceneScope + indexed_ids + elseif length(indexed_ids) * 4 <= length(model.registry.objects) + ObjectId[ + id for id in indexed_ids + if _scope_contains_object(model, scope, context, id) + ] + else + scoped_ids = Set(_scope_object_ids(model, scope, context)) + ObjectId[id for id in indexed_ids if id in scoped_ids] + end + else + related_ids = _relation_object_ids(model, relation, context) + if isnothing(explicit_scope) + related_ids + else + scoped_ids = Set(_scope_object_ids(model, explicit_scope, context)) + ObjectId[id for id in related_ids if id in scoped_ids] + end + end + ids = ObjectId[ + id for id in candidate_ids + if _matches_object_criteria(_model_object(model, id); scale=scale, kind=kind, species=species, name=name) + ] + _sort_object_ids!(ids) + + diagnostic_candidate_ids = if isempty(ids) && !isnothing(indexed_ids) + if isnothing(relation) + _scope_object_ids(model, scope, context) + else + related_ids = _relation_object_ids(model, relation, context) + if isnothing(explicit_scope) + related_ids + else + scoped_ids = Set(_scope_object_ids(model, explicit_scope, context)) + ObjectId[id for id in related_ids if id in scoped_ids] + end + end + else + candidate_ids + end + + if selector isa One && length(ids) != 1 + _selector_resolution_error( + model, + selector, + diagnostic_candidate_ids, + ids; + context=context, + scale=scale, + kind=kind, + species=species, + name=name, + ) + elseif selector isa OptionalOne && length(ids) > 1 + _selector_resolution_error( + model, + selector, + diagnostic_candidate_ids, + ids; + context=context, + scale=scale, + kind=kind, + species=species, + name=name, + ) + end + return ids +end + +resolve_objects(model::CompositeModel, selector::AbstractObjectMultiplicity; context=nothing) = + [_model_object(model, id) for id in resolve_object_ids(model, selector; context=context)] + +function _default_dependency_scope(model::CompositeModel, context::ObjectId) + object = _model_object(model, context) + (object.scale == :Scene || object.kind == :scene) && return SceneScope() + return Self() +end diff --git a/src/composite_model_api.jl b/src/composite_model_api.jl new file mode 100644 index 000000000..d63961ed5 --- /dev/null +++ b/src/composite_model_api.jl @@ -0,0 +1,9 @@ +# The Composite Model/Object API is one public compiler and runtime. Internal ownership is +# split by dependency direction; these files are not modules and add no public +# abstraction boundary. +include("composite_model/registry_topology.jl") +include("composite_model/selectors.jl") +include("composite_model/compilation.jl") +include("composite_model/environment_bindings.jl") +include("composite_model/runtime_outputs.jl") +include("composite_model/scenario_dsl.jl") diff --git a/src/dataframe.jl b/src/dataframe.jl deleted file mode 100644 index 2a9fba567..000000000 --- a/src/dataframe.jl +++ /dev/null @@ -1,69 +0,0 @@ -""" - DataFrame(components <: AbstractArray{<:ModelMapping}) - DataFrame(components <: AbstractDict{N,<:ModelMapping}) - -Fetch the data from a [`ModelMapping`](@ref) (or an Array/Dict of) status into a DataFrame. - -# Examples - -```@example -using PlantSimEngine -using DataFrames - -# Creating a ModelMapping -models = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=15.0, var2=0.3) -) - -# Converting to a DataFrame -df = DataFrame(models) - -# Converting to a Dict of ModelMappings -models = ModelMapping( - :Leaf => ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model() - ), - :InterNode => ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model() - ) -) - -# Converting to a DataFrame -df = DataFrame(models) -``` -""" -function DataFrames.DataFrame(components::T) where {T<:AbstractArray{<:ModelMapping}} - df = DataFrame[] - for (k, v) in enumerate(components) - df_c = DataFrames.DataFrame(v) - df_c[!, :component] .= k - push!(df, df_c) - end - reduce(vcat, df) -end - -function DataFrames.DataFrame(components::T) where {T<:AbstractDict{N,<:ModelMapping} where {N}} - df = DataFrames.DataFrame[] - for (k, v) in components - df_c = DataFrames.DataFrame(v) - df_c[!, :component] .= k - push!(df, df_c) - end - reduce(vcat, df) -end - -""" - DataFrame(components::ModelMapping{T,S}) where {T,S<:Status} - -Implementation of `DataFrame` for a `ModelMapping` model with one time step. -""" -function DataFrames.DataFrame(components::ModelMapping{T}) where {T} - DataFrames.DataFrame([NamedTuple(status(components)[1])]) -end diff --git a/src/dependencies/dependencies.jl b/src/dependencies/dependencies.jl deleted file mode 100644 index d59031c17..000000000 --- a/src/dependencies/dependencies.jl +++ /dev/null @@ -1,129 +0,0 @@ -dep(::T, nsteps=1) where {T<:AbstractModel} = NamedTuple() - -""" - dep(mapping::ModelMapping; verbose=true) - dep(mapping::AbstractDict{Symbol,T}; verbose=true) - dep!(m::ModelMapping, nsteps=1) - -Get the model dependency graph given a ModelMapping or a multiscale model mapping. If one graph is returned, -then all models are coupled. If several graphs are returned, then only the models inside each graph are coupled, and -the models in different graphs are not coupled. -`nsteps` is the number of steps the dependency graph will be used over. It is used to determine -the length of the `simulation_id` argument for each soft dependencies in the graph. It is set to `1` in the case of a -multiscale mapping. - -# Details - -The dependency graph is computed by searching the inputs of each process in the outputs of its own scale, or the other scales. There are five cases -for every model (one model simulates one process): - -1. The process has no inputs. It is completely independent, and is placed as one of the roots of the dependency graph. -2. The process needs inputs from models at its own scale. We put it as a child of this other process. -3. The process needs inputs from another scale. We put it as a child of this process at another scale. -4. The process needs inputs from its own scale and another scale. We put it as a child of both. -5. The process is a hard dependency of another process (only possible at the same scale). In this case, the process is set as a hard-dependency of the -other process, and its simulation is handled directly from this process. - -For the 4th case, the process have two parent processes. This is OK because the process will only be computed once during simulation as we check if both -parents were run before running the process. - -Note that in the 5th case, we still need to check if a variable is needed from another scale. In this case, the parent node is -used as a child of the process at the other scale. Note there can be several levels of hard dependency graph, so this is done recursively. - -How do we do all that? We identify the hard dependencies first. Then we link the inputs/outputs of the hard dependencies roots -to other scales if needed. Then we transform all these nodes into soft dependencies, that we put into a Dict of Scale => ModelMapping(process => SoftDependencyNode). -Then we traverse all these and we set nodes that need outputs from other nodes as inputs as children/parents. -If a node has no dependency, it is set as a root node and pushed into a new Dict (independant_process_root). This Dict is the returned dependency graph. And -it presents root nodes as independent starting points for the sub-graphs, which are the models that are coupled together. We can then traverse each of -these graphs independently to r - -# Notes - -The difference between `dep(m::ModelMapping)` and `dep!(m::ModelMapping, nsteps)` is that the first one returns the dependency graph found in the model list, while the -second one returns the dependency graph with the specified number of steps, modifying the simulation IDs of each node in the graph (`simulation_id=fill(0, nsteps)`). - -# Examples - -```@example -using PlantSimEngine - -# Including example processes and models: -using PlantSimEngine.Examples; - -models = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=15.0, var2=0.3) -) - -dep(models) - -# or directly with the processes: -models = ( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - process4=Process4Model(), - process5=Process5Model(), - process6=Process6Model(), - process7=Process7Model(), -) - -dep(;models...) -``` -""" -function dep(nsteps=1; verbose::Bool=true, vars...) - hard_dep = hard_dependencies((; vars...), verbose=verbose) - deps = soft_dependencies(hard_dep, nsteps) - - # Return the dependency graph - return deps -end - -function dep(m::ModelList) - m.dependency_graph -end - -function dep!(m::ModelList, nsteps=1) - traverse_dependency_graph!(m.dependency_graph; visit_hard_dep=false) do node - if length(node.simulation_id) != nsteps - node.simulation_id = fill(0, nsteps) - end - end - - return m.dependency_graph -end - - -function dep(m::NamedTuple, nsteps=1; verbose::Bool=true) - dep(nsteps; verbose=verbose, m...) -end - -function dep(mapping::AbstractDict{Symbol,T}; verbose::Bool=true) where {T} - # First step, get the hard-dependency graph and create SoftDependencyNodes for each hard-dependency root. In other word, we want - # only the nodes that are not hard-dependency of other nodes. These nodes are taken as roots for the soft-dependency graph because they - # are independant. - soft_dep_graphs_roots, hard_dep_dict = hard_dependencies(mapping; verbose=verbose) - - mapped_vars = mapped_variables(mapping, soft_dep_graphs_roots, verbose=false) - reverse_multiscale_mapping = reverse_mapping(mapped_vars, all=false) - - # Second step, compute the soft-dependency graph between SoftDependencyNodes computed in the first step. To do so, we search the - # inputs of each process into the outputs of the other processes, at the same scale, but also between scales. Then we keep only the - # nodes that have no soft-dependencies, and we set them as root nodes of the soft-dependency graph. The other nodes are set as children - # of the nodes that they depend on. - dep_graph = soft_dependencies_multiscale(soft_dep_graphs_roots, reverse_multiscale_mapping, hard_dep_dict) - # During the building of the soft-dependency graph, we identified the inputs and outputs of each dependency node, - # and also defined **inputs** as MappedVar if they are multiscale, i.e. if they take their values from another scale. - # What we are missing is that we need to also define **outputs** as multiscale if they are needed by another scale. - - # Checking that the graph is acyclic: - iscyclic, cycle_vec = is_graph_cyclic(dep_graph; warn=false) - # Note: we could do that in `soft_dependencies_multiscale` but we prefer to keep the function as simple as possible, and - # usable on its own. - - iscyclic && error("Cyclic dependency detected in the graph. Cycle: \n $(print_cycle(cycle_vec)) \n You can break the cycle using the `PreviousTimeStep` variable in the mapping.") - # Third step, we identify which - return dep_graph -end diff --git a/src/dependencies/dependency_graph.jl b/src/dependencies/dependency_graph.jl deleted file mode 100644 index 7063b77e9..000000000 --- a/src/dependencies/dependency_graph.jl +++ /dev/null @@ -1,158 +0,0 @@ -abstract type AbstractDependencyNode end - -mutable struct HardDependencyNode{T} <: AbstractDependencyNode - value::T - process::Symbol - dependency::NamedTuple - missing_dependency::Vector{Int} - scale::Symbol - inputs - outputs - parent::Union{Nothing,<:AbstractDependencyNode} - children::Vector{HardDependencyNode} -end - -mutable struct SoftDependencyNode{T} <: AbstractDependencyNode - value::T - process::Symbol - scale::Symbol - inputs - outputs - hard_dependency::Vector{HardDependencyNode} - parent::Union{Nothing,Vector{SoftDependencyNode}} - parent_vars::Union{Nothing,NamedTuple} - children::Vector{SoftDependencyNode} - simulation_id::Vector{Int} # id of the simulation -end - -# Add methods to check if a node is parallelizable: -object_parallelizable(x::T) where {T<:AbstractDependencyNode} = x.value => object_parallelizable(x.value) -timestep_parallelizable(x::T) where {T<:AbstractDependencyNode} = x.value => timestep_parallelizable(x.value) - -""" - DependencyGraph{T}(roots::T, not_found::Dict{Symbol,DataType}) - -A graph of dependencies between models. - -# Arguments - -- `roots::T`: the root nodes of the graph. -- `not_found::Dict{Symbol,DataType}`: the models that were not found in the graph. -""" -struct DependencyGraph{T,N} - roots::T - not_found::Dict{Symbol,N} -end - -# Add methods to check if a node is parallelizable: -function which_timestep_parallelizable(x::T) where {T<:DependencyGraph} - return traverse_dependency_graph(x, timestep_parallelizable) -end - -function which_object_parallelizable(x::T) where {T<:DependencyGraph} - return traverse_dependency_graph(x, object_parallelizable) -end - -object_parallelizable(x::T) where {T<:DependencyGraph} = all([i.second.second for i in which_object_parallelizable(x)]) -timestep_parallelizable(x::T) where {T<:DependencyGraph} = all([i.second.second for i in which_timestep_parallelizable(x)]) - -AbstractTrees.children(t::AbstractDependencyNode) = t.children -AbstractTrees.nodevalue(t::AbstractDependencyNode) = t.value # needs recent AbstractTrees -AbstractTrees.ParentLinks(::Type{<:AbstractDependencyNode}) = AbstractTrees.StoredParents() -AbstractTrees.parent(t::AbstractDependencyNode) = t.parent -AbstractTrees.printnode(io::IO, node::HardDependencyNode{T}) where {T} = print(io, T) -AbstractTrees.printnode(io::IO, node::SoftDependencyNode{T}) where {T} = print(io, T) -Base.show(io::IO, t::AbstractDependencyNode) = AbstractTrees.print_tree(io, t) -Base.length(t::AbstractDependencyNode) = length(collect(AbstractTrees.PreOrderDFS(t))) -Base.length(t::DependencyGraph) = length(traverse_dependency_graph(t)) -AbstractTrees.children(t::DependencyGraph) = collect(t.roots) - -# Long form printing -function Base.show(io::IO, ::MIME"text/plain", t::DependencyGraph) - # If the graph is cyclic, we print the cycle because we can't print indefinitely: - iscyclic, cycle_vec = is_graph_cyclic(t; warn=false, full_stack=true) - if iscyclic - print(io, "⚠ Cyclic dependency graph: \n $(print_cycle(cycle_vec))") - return nothing - else - draw_dependency_graph(io, t) - end -end - -""" - variables_multiscale(node, organ, mapping, st=NamedTuple()) - -Get the variables of a HardDependencyNode, taking into account the multiscale mapping, *i.e.* -defining variables as `MappedVar` if they are mapped to another scale. The default values are -taken from the model if not given by the user (`st`), and are marked as `UninitializedVar` if -they are inputs of the node. - -Return a NamedTuple with the variables and their default values. - -# Arguments - -- `node::HardDependencyNode`: the node to get the variables from. -- `organ::Symbol`: the organ type, *e.g.* :`Leaf`. -- `vars_mapping::Dict{String,T}`: the mapping of the models (see details below). -- `st::NamedTuple`: an optional named tuple with default values for the variables. - -# Details - -The `vars_mapping` is a dictionary with the organ type as key and a dictionary as value. It is -computed from the user mapping like so: -""" -function variables_multiscale(node, organ, vars_mapping, st=NamedTuple()) - node_vars = variables(node) # e.g. (inputs = (:var1=-Inf, :var2=-Inf), outputs = (:var3=-Inf,)) - ins = node_vars.inputs - ins_variables = keys(ins) - outs_variables = keys(node_vars.outputs) - defaults = merge(node_vars...) - map((inputs=ins_variables, outputs=outs_variables)) do vars # Map over vars from :inputs and vars from :outputs - vars_ = Vector{Pair{Symbol,Any}}() - for var in vars # e.g. var = :carbon_biomass - if var in keys(st) - #If the user has given a status, we use it as default value. - default = st[var] - elseif var in ins_variables - # Otherwise, we use the default value given by the model: - # If the variable is an input, we mark it as uninitialized: - default = UninitializedVar(var, defaults[var]) - else - # If the variable is an output, we use the default value given by the model: - default = defaults[var] - end - - if haskey(vars_mapping[organ], var) - organ_mapped, organ_mapped_var = _node_mapping(vars_mapping[organ][var]) - push!(vars_, var => MappedVar(organ_mapped, var, organ_mapped_var, default)) - #* We still check if the variable also exists wrapped in PreviousTimeStep, because one model could use the current - #* values, and another one the previous values. - if haskey(vars_mapping[organ], PreviousTimeStep(var, node.process)) - organ_mapped, organ_mapped_var = _node_mapping(vars_mapping[organ][PreviousTimeStep(var, node.process)]) - push!(vars_, var => MappedVar(organ_mapped, PreviousTimeStep(var, node.process), organ_mapped_var, default)) - end - elseif haskey(vars_mapping[organ], PreviousTimeStep(var, node.process)) - # If not found in the current time step, we check if the variable is mapped to the previous time step: - organ_mapped, organ_mapped_var = _node_mapping(vars_mapping[organ][PreviousTimeStep(var, node.process)]) - push!(vars_, var => MappedVar(organ_mapped, PreviousTimeStep(var, node.process), organ_mapped_var, default)) - else - # Else we take the default value: - push!(vars_, var => default) - end - end - return (; vars_...,) - end -end - -function _node_mapping(var_mapping::Pair{<:Union{AbstractString,Symbol},Symbol}) - # One organ is mapped to the variable: - return SingleNodeMapping(first(var_mapping)), last(var_mapping) -end - -function _node_mapping(var_mapping) - # Several organs are mapped to the variable: - organ_mapped = MultiNodeMapping([first(i) for i in var_mapping]) - organ_mapped_var = [last(i) for i in var_mapping] - - return organ_mapped, organ_mapped_var -end diff --git a/src/dependencies/get_model_in_dependency_graph.jl b/src/dependencies/get_model_in_dependency_graph.jl deleted file mode 100644 index cf153e98c..000000000 --- a/src/dependencies/get_model_in_dependency_graph.jl +++ /dev/null @@ -1,43 +0,0 @@ -""" - get_model_nodes(dep_graph::DependencyGraph, model) - -Get the nodes in the dependency graph implementing a type of model. - -# Arguments - -- `dep_graph::DependencyGraph`: the dependency graph. -- `model`: the model type to look for. - -# Returns - -- An array of nodes implementing the model type. - -# Examples - -```julia -PlantSimEngine.get_model_nodes(dependency_graph, Beer) -``` -""" -function get_model_nodes(dep_graph::DependencyGraph, model) - model_node = Union{SoftDependencyNode,HardDependencyNode}[] - - traverse_dependency_graph!(dep_graph) do node - if isa(node.value, model) - push!(model_node, node) - end - end - - return model_node -end - -function get_model_nodes(dep_graph::DependencyGraph, process::Symbol) - process_node = Union{SoftDependencyNode,HardDependencyNode}[] - - traverse_dependency_graph!(dep_graph) do node - if node.process == process - push!(process_node, node) - end - end - - return process_node -end \ No newline at end of file diff --git a/src/dependencies/hard_dependencies.jl b/src/dependencies/hard_dependencies.jl deleted file mode 100644 index ba623980d..000000000 --- a/src/dependencies/hard_dependencies.jl +++ /dev/null @@ -1,290 +0,0 @@ -""" - hard_dependencies(models; verbose::Bool=true) - hard_dependencies(mapping::ModelMapping; verbose::Bool=true) - hard_dependencies(mapping::AbstractDict{Symbol,T}; verbose::Bool=true) - -Compute the hard dependencies between models. -""" -function _normalize_hard_dependency_scales(scales, process::Symbol, dependency_process::Symbol) - if scales isa Symbol || scales isa AbstractString - return [Symbol(scales)] - elseif scales isa Tuple || scales isa AbstractVector - normalized = Symbol[] - for s in scales - if s isa Symbol || s isa AbstractString - push!(normalized, Symbol(s)) - else - error( - "Invalid hard dependency scale declaration for process `$(process)` dependency `$(dependency_process)`: ", - "expected Symbol or String scales, got `$(typeof(s))`." - ) - end - end - isempty(normalized) && error( - "Invalid hard dependency scale declaration for process `$(process)` dependency `$(dependency_process)`: ", - "at least one target scale must be provided." - ) - return normalized - end - - error( - "Invalid hard dependency scale declaration for process `$(process)` dependency `$(dependency_process)`: ", - "expected Symbol/String or a tuple/vector of them, got `$(typeof(scales))`." - ) -end - -function hard_dependencies(models; scale=nothing, verbose::Bool=true) - dep_graph = initialise_all_as_hard_dependency_node(models, scale) - dep_not_found = Dict{Symbol,Any}() - for (process, i) in pairs(models) # for each model in the model list. process=:state; i=pairs(models)[process] - level_1_dep = dep(i) # we get the required types for the model dependencies - length(level_1_dep) == 0 && continue # if there is no dependency we skip the iteration - dep_graph[process].dependency = level_1_dep - for (p, depend) in pairs(level_1_dep) # for each dependency of the model i. p=:leaf_rank; depend=pairs(level_1_dep)[p] - # The dependency can be given as multiscale, e.g. `leaf_area=AbstractLeaf_AreaModel => [m.leaf_symbol],` - # This means we should search this model in another scale. This is not done here, but after the call to this - # function in the other method for `hard_dependencies` below. - if isa(depend, Pair) - if !isnothing(scale) - # We skip this hard-dependency if it is multiscale, we compute this afterwards in this case - target_scales = _normalize_hard_dependency_scales(last(depend), process, p) - push!(dep_not_found, p => (parent_process=process, type=first(depend), scales=target_scales)) - continue - else - # If we are not in a multi-scale setup e.g. in a ModelList, we shouldn't use a multiscale model. - # But we still authorize it with a warning, and then proceed searching the dependency in this model list. - verbose && @warn "Model $i has a multiscale hard dependency on $(first(depend)): $depend. Trying to find the model in this scale instead." - depend = first(depend) - end - end - - if hasproperty(models, p) - if typeof(getfield(models, p)) <: depend - parent_dep = dep_graph[process] - push!(parent_dep.children, dep_graph[p]) - for child in parent_dep.children - child.parent = parent_dep - end - else - if verbose - @info string( - "Model ", typeof(i).name.name, " from process ", process, - isnothing(scale) ? "" : " at scale $scale", - " needs a model that is a subtype of ", depend, " in process ", - p - ) - end - - push!(dep_not_found, p => depend) - - push!( - dep_graph[process].missing_dependency, - findfirst(x -> x == p, keys(level_1_dep)) - ) # index of the missing dep - # NB: we can retreive missing deps using dep_graph[process].dependency[dep_graph[process].missing_dependency] - end - else - if verbose - @info string( - "Model ", typeof(i).name.name, " from process ", process, - isnothing(scale) ? "" : " at scale $scale", - " needs a model that is a subtype of ", depend, " in process ", - p, ", but the process is not parameterized in the ModelList." - ) - end - push!(dep_not_found, p => depend) - - push!( - dep_graph[process].missing_dependency, - findfirst(x -> x == p, keys(level_1_dep)) - ) # index of the missing dep - # NB: we can retreive missing deps using dep_graph[process].dependency[dep_graph[process].missing_dependency] - end - end - end - - roots = [AbstractTrees.getroot(i) for i in values(dep_graph)] - # Keeping only the graphs with no common root nodes, i.e. remove graphs that are part of a - # bigger dependency graph: - unique_roots = Dict{Symbol,HardDependencyNode}() - for (p, m) in dep_graph - if m in roots - push!(unique_roots, p => m) - end - end - - return DependencyGraph(unique_roots, dep_not_found) -end - -""" - initialise_all_as_hard_dependency_node(models) - -Take a set of models and initialise them all as a hard dependency node, and -return a dictionary of `:process => HardDependencyNode`. -""" -function initialise_all_as_hard_dependency_node(models, scale) - node_scale = isnothing(scale) ? :Default : Symbol(scale) - dep_graph = Dict( - p => HardDependencyNode( - i, - p, - NamedTuple(), - Int[], - node_scale, - inputs_(i), - outputs_(i), - nothing, - HardDependencyNode[] - ) for (p, i) in pairs(models) - ) - - return dep_graph -end - - -# When we use a mapping (multiscale), we return the set of soft-dependencies (we put the hard-dependencies as their children): -function hard_dependencies(mapping::AbstractDict{Symbol,T}; verbose::Bool=true) where {T} - full_vars_mapping = Dict(first(mod) => Dict(get_mapped_variables(last(mod))) for mod in mapping) - soft_dep_graphs = Dict{Symbol,Any}() - not_found = Dict{Symbol,DataType}() - - mods = Dict(organ => parse_models(get_models(model)) for (organ, model) in mapping) - - # For each scale, move the hard-dependency models as children of the its parent model. - # Note: this is mono-scale at this point (computes each scale independently) - # Since the hard dependencies are inserted into the soft dependency graph as children and aren't referenced elsewhere - # it becomes harder to keep track of them as needed without traversing the graph - # so keep tabs on them during initialisation until they're no longer needed - hard_dependency_dict = Dict{Pair{Symbol,Symbol},HardDependencyNode}() - - hard_deps = Dict(organ => hard_dependencies(mods_scale, scale=organ, verbose=false) for (organ, mods_scale) in mods) - - # Compute the inputs and outputs of all "root" node of the hard dependencies, so the root - # node that takes control over other models appears to have the union of its own inputs (resp. outputs) - # and the ones from its hard dependencies. - #* Note that we compute this before computing the multiscale hard dependencies because the inputs/outputs - #* of hard-dependency models should remain in their own scale. Note that the variables from the hard - #* dependency may not appear in its own scale, but this is treated in the soft-dependency computation - inputs_process = Dict{Symbol,Dict{Symbol,Vector}}() - outputs_process = Dict{Symbol,Dict{Symbol,Vector}}() - for (organ, model) in mapping - # Get the status given by the user, that is used to set the default values of the variables in the mapping: - st_scale_user = get_status(model) - if isnothing(st_scale_user) - st_scale_user = NamedTuple() - else - st_scale_user = NamedTuple(st_scale_user) - end - - status_scale = Dict{Symbol,Vector{Pair{Symbol,NamedTuple}}}() - for (procname, node) in hard_deps[organ].roots # procname = :leaf_surface ; node = hard_deps.roots[procname] - var = Pair{Symbol,NamedTuple}[] - traverse_dependency_graph!(node, x -> variables_multiscale(x, organ, full_vars_mapping, st_scale_user), var) - push!(status_scale, procname => var) - end - - inputs_process[organ] = Dict(key => [j.first => j.second.inputs for j in val] for (key, val) in status_scale) - outputs_process[organ] = Dict(key => [j.first => j.second.outputs for j in val] for (key, val) in status_scale) - end - - # If some models needed as hard-dependency are not found in their own scale, check the other scales: - for (organ, model) in mapping - # organ = :Plant; model = mapping[organ] - # filtering the hard dependency that were defined as multiscale (NamedTuple with information) - multiscale_hard_dep = filter(x -> isa(last(x), NamedTuple), hard_deps[organ].not_found) - for (p, (parent_process, model_type, scales)) in multiscale_hard_dep - # debug: p = :initiation_age; parent_process, model_type, scales = multiscale_hard_dep[p] - parent_node = get_model_nodes(hard_deps[organ], parent_process) - if length(parent_node) == 0 - continue - end - parent_node = only(parent_node) - # The parent node is the one that needs the hard dependency we are searching - is_found = Ref(false) # Flag to check if the model was found in the other scales - for s in scales # s="Phytomer" - dep_node_model = filter(x -> x.scale == s, get_model_nodes(hard_deps[s], p)) - # Note: here we apply a filter because we modify the graph dynamically, and sometimes - # we have already computed multiscale hard-dependencies, which can show up here, - # so we only keep the models that were declared at the scale we are looking. - - if length(dep_node_model) > 0 - is_found[] = true - else - error("Model `$(typeof(parent_node.value))` from scale $organ requires a model of type `$model_type` at scale $s as a hard dependency, but no model was found for this process.") - end - dep_node_model = only(dep_node_model) - - if !isa(dep_node_model.value, model_type) - error("Model `$(typeof(parent_node.value))` from scale $organ requires a model of type `$model_type` at scale $s as a hard dependency, but the model found for this process is of type $(typeof(dep_node_model.value)).") - end - - # We make a new node out of the previous one: - new_node = HardDependencyNode( - dep_node_model.value, - dep_node_model.process, - dep_node_model.dependency, - dep_node_model.missing_dependency, - dep_node_model.scale, - dep_node_model.inputs, - dep_node_model.outputs, - parent_node, - dep_node_model.children - ) - - # Add our new node as a child of the parent node (the one that requires it as a hard dependency) - push!(parent_node.children, new_node) - - # previously created nested hard dependency nodes' ancestors that have the new_node model as their caller now point to an outdated parent - # (and hard dependency node in an outdated state), so their grandparent when traversing upwards might incorrectly be set to nothing - # update their parent to the correct new node - for ((hd_sym, hd_scale), hd_node) in hard_dependency_dict - - if (hd_node.parent.process == p) && (hd_node.scale == hd_scale) - hd_node.parent = new_node - end - end - - # add the new node to the flat list of hard deps, as they aren't trivial to access in the dep graph, and we might need them later for a couple of things - hard_dependency_dict[Pair(p, new_node.scale)] = new_node - - # If it was a root node, we delete it as a root node. - if dep_node_model in values(hard_deps[s].roots) - delete!(hard_deps[s].roots, p) # We delete the value that has the process as key - end - end - # If the model was found in at least one another scale, delete it from the not_found Dict - is_found[] && delete!(hard_deps[organ].not_found, p) - end - end - - for (organ, model) in mapping - soft_dep_graph = Dict( - process_ => SoftDependencyNode( - soft_dep_vars.value, - process_, # process name - organ, # scale - inputs_process[organ][process_], # These are the inputs, potentially multiscale - outputs_process[organ][process_], # Same for outputs - AbstractTrees.children(soft_dep_vars), # hard dependencies - nothing, - nothing, - SoftDependencyNode[], - [0] # Vector of zeros of length = number of time-steps - ) - for (process_, soft_dep_vars) in hard_deps[organ].roots # proc_ = :carbon_assimilation ; soft_dep_vars = hard_deps.roots[proc_] - ) - - # Update the parent node of the hard dependency nodes to be the new SoftDependencyNode instead of the old - # HardDependencyNode. - for (p, node) in soft_dep_graph - for n in node.hard_dependency - n.parent = node - end - end - - soft_dep_graphs[organ] = (soft_dep_graph=soft_dep_graph, inputs=inputs_process[organ], outputs=outputs_process[organ]) - not_found = merge(not_found, hard_deps[organ].not_found) - end - - return (DependencyGraph(soft_dep_graphs, not_found), hard_dependency_dict) -end diff --git a/src/dependencies/is_graph_cyclic.jl b/src/dependencies/is_graph_cyclic.jl deleted file mode 100644 index 918eb28b7..000000000 --- a/src/dependencies/is_graph_cyclic.jl +++ /dev/null @@ -1,79 +0,0 @@ -""" - is_graph_cyclic(dependency_graph::DependencyGraph; full_stack=false, verbose=true) - -Check if the dependency graph is cyclic. - -# Arguments - -- `dependency_graph::DependencyGraph`: the dependency graph to check. -- `full_stack::Bool=false`: if `true`, return the full stack of nodes that makes the cycle, otherwise return only the cycle. -- `warn::Bool=true`: if `true`, print a stylised warning message when a cycle is detected. - -Return a boolean indicating if the graph is cyclic, and the stack of nodes as a vector. -""" -function is_graph_cyclic(dependency_graph::DependencyGraph; full_stack=false, warn=true) - visited = Dict{Pair{AbstractModel,Symbol},Bool}() - recursion_stack = Dict{Pair{AbstractModel,Symbol},Bool}() - for node in values(dependency_graph.roots) - visited[node.value=>node.scale] = false - recursion_stack[node.value=>node.scale] = false - end - - for (root, node) in dependency_graph.roots - cycle_vec = Vector{Pair{AbstractModel,Symbol}}() - if is_graph_cyclic_(node, visited, recursion_stack, cycle_vec) - - if full_stack - push!(cycle_vec, node.value => node.scale) - else - # Keep just the cycle (the first node in the vector is the one that makes a cycle, we just detect the second time it happens on the stack): - cycled_nodes = findall(x -> x == cycle_vec[1], cycle_vec) - cycle_vec = cycle_vec[1:cycled_nodes[2]] - end - - warn && @warn "Cyclic dependency detected in the graph: \n $(print_cycle(cycle_vec))" - - return true, cycle_vec - end - end - - return false, visited -end - -function is_graph_cyclic_(node, visited, recursion_stack, cycle_vec) - node_id = node.value => node.scale - visited[node_id] = true - recursion_stack[node_id] = true - - for child in node.children - child_id = child.value => child.scale - if !haskey(visited, child_id) && is_graph_cyclic_(child, visited, recursion_stack, cycle_vec) - push!(cycle_vec, child_id) - return true - elseif haskey(recursion_stack, child_id) && recursion_stack[child_id] - push!(cycle_vec, child_id) - return true - end - end - - recursion_stack[node_id] = false - return false -end - -function print_cycle(cycle_vec) - printed_cycle = Any[Term.RenderableText(string("{bold red}", last(cycle_vec[1]), ": ", typeof(first(cycle_vec[1]))))] - leading_space = [1] - for (m, s) in cycle_vec[2:end] - node_print = string(repeat(" ", leading_space[1]), "└ ", s, ": ", typeof(m)) - if (m => s) == cycle_vec[1] - node_print = Term.RenderableText("{bold red}$node_print") - else - node_print = Term.RenderableText(node_print) - end - - push!(printed_cycle, node_print) - leading_space[1] += 1 - end - - return join(printed_cycle, "") -end diff --git a/src/dependencies/printing.jl b/src/dependencies/printing.jl deleted file mode 100644 index 39c38f061..000000000 --- a/src/dependencies/printing.jl +++ /dev/null @@ -1,146 +0,0 @@ -function draw_dependency_graph( - io, - graphs::DependencyGraph; - title=string("Dependency graph (", length(graphs), " models)"), - max_depth::Int=1, - title_style::String="#FFA726 italic", - guides_style::String="#42A5F5", - dep_graph_guides=(space=" ", vline="│", branch="├", leaf="└", hline="─") -) - - dep_graph_guides = map((g) -> Term.apply_style("{$guides_style}$g{/$guides_style}"), dep_graph_guides) - - max_depth_reached = Ref(max_depth) - - graph_panel = [] - for (p, graph) in graphs.roots - if max_depth_reached[] <= 0 - push!(graph_panel, "...") - break - end - node = [] - # p = :process2; graph = graphs.roots[p] - # typeof(deps[:process4].children[1].hard_dependency.children[1]) - draw_panel(node, graph, "", dep_graph_guides, graph, max_depth_reached; title="Main model") - push!(graph_panel, Term.Panel(node...; fit=true, title=string(p), style="green dim")) - max_depth_reached[] -= 1 - end - - print( - io, - Term.Panel( - graph_panel...; - fit=true, - title="{$(title_style)}$(title){/$(title_style)}", - style="$(title_style) dim" - ) - ) -end - -""" - draw_panel(node, graph, prefix, dep_graph_guides, parent; title="Soft-coupled model") - -Draw the panels for all dependencies -""" -function draw_panel(node, graph, prefix, dep_graph_guides, parent, max_depth_reached=Ref(5); title="Soft-coupled model") - - if max_depth_reached[] <= 0 - push!(node, "...") - return nothing - end - - # If the node has a sibling, draw a branching guide + a horizontal line: - if length(parent.children) <= 1 - is_leaf = true - else - is_leaf = false - end - - panel_hright = string(prefix, repeat(" ", 8)) - panel = draw_model_panel(graph; title=title) - - if graph.parent === nothing && parent == graph - # The current node is the root of the graph: - push!(node, prefix * panel) - else - push!( - node, - draw_guide( - panel.measure.h ÷ 2, - 3, - panel_hright, - is_leaf, - dep_graph_guides - ) * panel - ) - end - # Draw the hard dependencies if any: - if graph isa SoftDependencyNode - # draw a branching guide if there's more soft dependencies after this one: - for child in graph.hard_dependency - draw_panel(node, child, panel_hright, dep_graph_guides, graph, max_depth_reached; title="Hard-coupled model") - max_depth_reached[] -= 1 - end - elseif isa(parent, SoftDependencyNode) && length(parent.children) > 0 - # The current node is a hard dependency of a soft dependency. - # If the parent has more soft dependency children, draw a vline also: - panel_hright = string(prefix, repeat(" ", 8), dep_graph_guides.vline) - end - - # Recursive call: - for child in AbstractTrees.children(graph) - draw_panel(node, child, panel_hright, dep_graph_guides, graph, max_depth_reached; title=title_panel(child)) - max_depth_reached[] -= 1 - end -end - -title_panel(i::SoftDependencyNode) = "Soft-coupled model" -title_panel(i::HardDependencyNode) = "Hard-coupled model" - -function draw_model_panel(i::SoftDependencyNode{T}; title=nothing) where {T} - Term.Panel( - title=title, - string( - "Process: $(i.process)\n", - "Model: $(T)\n", - "Dep: $(i.parent_vars)" - ); - fit=false, - style="blue dim" - ) -end - - -function draw_model_panel(i::HardDependencyNode{T}; title=nothing) where {T} - Term.Panel( - title=title, - string( - "Process: $(i.process)\n", - "Model: $(T)", - length(i.missing_dependency) == 0 ? "" : string( - "\n{red underline}Missing dependencies: ", - join([i.dependency[j] for j in i.missing_dependency], ", "), - "{/red underline}" - ) - ); - fit=true, - style="red dim" - ) -end - - -""" - draw_guide(h, w, prefix, isleaf, guides) - -Draw the line guide for one node of the dependency graph. -""" -function draw_guide(h, w, prefix, isleaf, guides) - header_width = string(prefix, guides.vline, repeat(guides.space, w - 1), "\n") - header = h > 1 ? repeat(header_width, h) : "" - if isleaf - return header * prefix * guides.leaf * repeat(guides.hline, w - 1) - else - footer = h > 1 ? header_width[1:end-1] : "" # NB: we remove the last \n - return header * prefix * guides.branch * repeat(guides.hline, w - 1) * "\n" * footer - end -end diff --git a/src/dependencies/soft_dependencies.jl b/src/dependencies/soft_dependencies.jl deleted file mode 100644 index ecf36a8bd..000000000 --- a/src/dependencies/soft_dependencies.jl +++ /dev/null @@ -1,582 +0,0 @@ -""" - soft_dependencies(d::DependencyGraph) - -Return a [`DependencyGraph`](@ref) with the soft dependencies of the processes in the dependency graph `d`. -A soft dependency is a dependency that is not explicitely defined in the model, but that -can be inferred from the inputs and outputs of the processes. - -# Arguments - -- `d::DependencyGraph`: the hard-dependency graph. - -# Example - -```julia -using PlantSimEngine - -# Load the dummy models given as example in the package: -using PlantSimEngine.Examples - -# Create a model list: -models = ModelList( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - process4=Process4Model(), - process5=Process5Model(), - process6=Process6Model(), -) - -# Create the hard-dependency graph: -hard_dep = hard_dependencies(models.models, verbose=true) - -# Get the soft dependencies graph: -soft_dep = soft_dependencies(hard_dep) -``` -""" -function soft_dependencies(d::DependencyGraph{Dict{Symbol,HardDependencyNode}}, nsteps=1) - - # Compute the variables of each node in the hard-dependency graph: - d_vars = Dict{Symbol,Vector{Pair{Symbol,NamedTuple}}}() - for (procname, node) in d.roots - var = Pair{Symbol,NamedTuple}[] - nodes_visited = Set{AbstractDependencyNode}() - traverse_dependency_graph!(node, variables, var; node_visited=nodes_visited) - push!(d_vars, procname => var) - end - - # Note: all variables are collected at once for each hard-coupled nodes - # because they are treated as one process afterwards (see below) - - # Get all nodes of the dependency graph (hard and soft): - # all_nodes = Dict(traverse_dependency_graph(d, x -> x)) - - # Compute the inputs and outputs of each process graph in the dependency graph - inputs_process = Dict{Symbol,Vector{Pair{Symbol,Tuple{Vararg{Symbol}}}}}( - key => [j.first => keys(j.second.inputs) for j in val] for (key, val) in d_vars - ) - outputs_process = Dict{Symbol,Vector{Pair{Symbol,Tuple{Vararg{Symbol}}}}}( - key => [j.first => keys(j.second.outputs) for j in val] for (key, val) in d_vars - ) - - soft_dep_graph = Dict( - process_ => SoftDependencyNode( - soft_dep_vars.value, - process_, # process name - soft_dep_vars.scale, - inputs_(soft_dep_vars.value), - outputs_(soft_dep_vars.value), - AbstractTrees.children(soft_dep_vars), # hard dependencies - nothing, - nothing, - SoftDependencyNode[], - fill(0, nsteps) - ) - for (process_, soft_dep_vars) in d.roots - ) - - independant_process_root = Dict{Symbol,SoftDependencyNode}() - for (proc, i) in soft_dep_graph - # proc = :process3; i = soft_dep_graph[proc] - # Search if the process has soft dependencies: - soft_deps = search_inputs_in_output(proc, inputs_process, outputs_process) - - # Remove the hard dependencies from the soft dependencies: - soft_deps_not_hard = drop_process(soft_deps, [hd.process for hd in i.hard_dependency]) - # NB: if a node is already a hard dependency of the node, it cannot be a soft dependency - - if length(soft_deps_not_hard) == 0 && i.process in keys(d.roots) - # If the process has no soft dependencies, then it is independant (so it is a root) - # Note that the process is only independent if it is also a root in the hard-dependency graph - independant_process_root[proc] = i - else - # If the process has soft dependencies, then it is not independant - # and we need to add its parent(s) to the node, and the node as a child - for (parent_soft_dep, soft_dep_vars) in pairs(soft_deps_not_hard) - # parent_soft_dep = :process5; soft_dep_vars = soft_deps[parent_soft_dep] - - # preventing a cyclic dependency - if parent_soft_dep == proc - error("Cyclic model dependency detected for process $proc") - end - - # preventing a cyclic dependency: if the parent also has a dependency on the current node: - if soft_dep_graph[parent_soft_dep].parent !== nothing && i in soft_dep_graph[parent_soft_dep].parent - error( - "Cyclic dependency detected for process $proc:", - " $proc depends on $parent_soft_dep, which depends on $proc.", - " This is not allowed, but is possible via a hard dependency." - ) - end - - # preventing a cyclic dependency: if the current node has the parent node as a child: - if i.children !== nothing && soft_dep_graph[parent_soft_dep] in i.children - error( - "Cyclic dependency detected for process $proc:", - " $proc depends on $parent_soft_dep, which depends on $proc.", - " This is not allowed, but is possible via a hard dependency." - ) - end - - # Add the current node as a child of the node on which it depends - push!(soft_dep_graph[parent_soft_dep].children, i) - - # Add the node on which the current node depends as a parent - if i.parent === nothing - # If the node had no parent already, it is nothing, so we change into a vector - i.parent = [soft_dep_graph[parent_soft_dep]] - else - push!(i.parent, soft_dep_graph[parent_soft_dep]) - end - - # Add the soft dependencies (variables) of the parent to the current node - i.parent_vars = soft_deps - end - end - end - - return DependencyGraph(independant_process_root, d.not_found) -end - -# For multiscale mapping: -function soft_dependencies_multiscale(soft_dep_graphs_roots::DependencyGraph{Dict{Symbol,Any}}, reverse_multiscale_mapping, hard_dep_dict::Dict{Pair{Symbol,Symbol},HardDependencyNode}) - - independant_process_root = Dict{Pair{Symbol,Symbol},SoftDependencyNode}() - for (organ, (soft_dep_graph, ins, outs)) in soft_dep_graphs_roots.roots # e.g. organ = :Plant; soft_dep_graph, ins, outs = soft_dep_graphs_roots.roots[organ] - for (proc, i) in soft_dep_graph - # proc = :leaf_surface; i = soft_dep_graph[proc] - # Search if the process has soft dependencies: - soft_deps = search_inputs_in_output(proc, ins, outs) - - # Remove the hard dependencies from the soft dependencies: - soft_deps_not_hard = drop_process(soft_deps, [hd.process for hd in i.hard_dependency]) - - hard_dependencies_from_other_scale = [hd for hd in i.hard_dependency if hd.scale != i.scale] - - # NB: if a node is already a hard dependency of the node, it cannot be a soft dependency - - # Check if the process has soft dependencies at other scales: - soft_deps_multiscale = search_inputs_in_multiscale_output(proc, organ, ins, soft_dep_graphs_roots.roots, reverse_multiscale_mapping, hard_dependencies_from_other_scale) - # Example output: :Soil => Dict(:soil_water=>[:soil_water_content]), which means that the variable :soil_water_content - # is computed by the process :soil_water at the scale :Soil. - - if length(soft_deps_not_hard) == 0 && i.process in keys(soft_dep_graph) && length(soft_deps_multiscale) == 0 - # If the process has no soft (multiscale) dependencies, then it is independant (so it is a root) - # Note that the process is only independent if it is also a root in the hard-dependency graph - independant_process_root[organ=>proc] = i - else - # If the process has soft dependencies at its scale, add it: - if length(soft_deps_not_hard) > 0 - # If the process has soft dependencies, then it is not independant - # and we need to add its parent(s) to the node, and the node as a child - for (parent_soft_dep, soft_dep_vars) in pairs(soft_deps_not_hard) - - # if the parent isn't registered as a soft dependency, it likely means the soft dependecy should be to an internal hard dependency to the parent - if (!haskey(soft_dep_graph, parent_soft_dep)) - - roots_at_given_scale = soft_dep_graphs_roots.roots[i.scale][:soft_dep_graph] - if !(parent_soft_dep in keys(roots_at_given_scale)) - master_node = () - for ((hd_key, hd_scale), hd) in hard_dep_dict - if parent_soft_dep == hd_key - master_node = hd - depth = 0 - # A cleaner way of preventing cycles or infinite loops would be more desirable - while !isa(master_node, SoftDependencyNode) && depth < 50 - master_node.parent === nothing && error("Finalised hard dependency has no parent") - master_node = master_node.parent - depth += 1 - end - - break - end - end - master_node == () && error("Parent is not located in hard deps, nor in roots, which should be the case when initalizing soft dependencies") - end - # NOTE : this may need to be propagated within internal hard dependencies' ancestors of this model... ? - parent_node = soft_dep_graphs_roots.roots[master_node.scale][:soft_dep_graph][master_node.process] - else - parent_node = soft_dep_graph[parent_soft_dep] - end - - - - # preventing a cyclic dependency - if parent_soft_dep == proc - error("Cyclic model dependency detected for process $proc from organ $organ.") - end - - # preventing a cyclic dependency: if the parent also has a dependency on the current node: - if parent_node.parent !== nothing && i in parent_node.parent - error( - "Cyclic dependency detected for process $proc from organ $organ:", - " $proc depends on $parent_soft_dep, which depends on $proc.", - " This is not allowed, but is possible via a hard dependency." - ) - end - - # preventing a cyclic dependency: if the current node has the parent node as a child: - if i.children !== nothing && parent_node in i.children - error( - "Cyclic dependency detected for process $proc from organ $organ:", - " $proc depends on $parent_soft_dep, which depends on $proc.", - " This is not allowed, but is possible via a hard dependency." - ) - end - - i in parent_node.children && error("Cyclic dependency detected for process $proc from organ $organ.") - - # Add the current node as a child of the node on which it depends - push!(parent_node.children, i) - - # Add the node on which the current node depends as a parent - if i.parent === nothing - # If the node had no parent already, it is nothing, so we change into a vector - i.parent = [parent_node] - else - parent_node in i.parent && error("Cyclic dependency detected for process $proc from organ $organ.") - push!(i.parent, parent_node) - end - - # Add the soft dependencies (variables) of the parent to the current node - i.parent_vars = soft_deps - end - end - - # If the node has soft dependencies at other scales, add it as child of the other scale (and add its parent too): - if length(soft_deps_multiscale) > 0 - for org in keys(soft_deps_multiscale) - for (parent_soft_dep, soft_dep_vars) in soft_deps_multiscale[org] - - # if the node has a soft dependency on a node that is a nested hard dependency, - # have it point to the master node of that hard dependency instead of the internal node - # This check is meant in case the organ at the inspected scale is part of a hard dependency, - # and therefore already absent from the roots - - roots_at_given_scale = soft_dep_graphs_roots.roots[org][:soft_dep_graph] - if !(parent_soft_dep in keys(roots_at_given_scale)) - master_node = () - for ((hd_key, hd_scale), hd) in hard_dep_dict - if parent_soft_dep == hd_key - master_node = hd - depth = 0 - # A cleaner way of preventing cycles or infinite loops would be more desirable - while !isa(master_node, SoftDependencyNode) && depth < 50 - master_node.parent === nothing && error("Finalised hard dependency has no parent") - master_node = master_node.parent - depth += 1 - end - - break - end - end - - master_node == () && error("Parent is not located in hard deps, nor in roots, which should be the case when initalizing soft dependencies") - - # NOTE : this may need to be propagated within internal hard dependencies' ancestors of this model... ? - parent_node = soft_dep_graphs_roots.roots[master_node.scale][:soft_dep_graph][master_node.process] - else - parent_node = soft_dep_graphs_roots.roots[org][:soft_dep_graph][parent_soft_dep] - end - - # preventing a cyclic dependency: if the parent also has a dependency on the current node: - if parent_node.parent !== nothing && any([i == p for p in parent_node.parent]) - error( - "Cyclic dependency detected for process $proc:", - " $proc for organ $organ depends on $parent_soft_dep from organ $org, which depends on the first one", - " This is not allowed, you may need to develop a new process that does the whole computation by itself." - ) - end - - # preventing a cyclic dependency: if the current node has the parent node as a child: - if i.children !== nothing && parent_node in i.children - error( - "Cyclic dependency detected for process $proc:", - " $proc for organ $organ depends on $parent_soft_dep from organ $org, which depends on the first one.", - " This is not allowed, you may need to develop a new process that does the whole computation by itself." - ) - end - - - if !(i in parent_node.children) # && error("Cyclic dependency detected for process $proc from organ $organ.") - - # Add the current node as a child of the node on which it depends: - push!(parent_node.children, i) - end - # Add the node on which the current node depends as a parent - if i.parent === nothing - # If the node had no parent already, it is nothing, so we change into a vector - i.parent = [parent_node] - else - if !(parent_node in i.parent) # && error("Cyclic dependency detected for process $proc from organ $organ.") - push!(i.parent, parent_node) - end - end - - # Add the multiscale soft dependencies variables of the parent to the current node - i.parent_vars = NamedTuple(Symbol(k) => NamedTuple(v) for (k, v) in soft_deps_multiscale) - end - end - end - end - end - end - - return DependencyGraph(independant_process_root, soft_dep_graphs_roots.not_found) -end - - -""" - drop_process(proc_vars, process) - -Return a new `NamedTuple` with the process `process` removed from the `NamedTuple` `proc_vars`. - -# Arguments - -- `proc_vars::NamedTuple`: the `NamedTuple` from which we want to remove the process `process`. -- `process::Symbol`: the process we want to remove from the `NamedTuple` `proc_vars`. - -# Returns - -A new `NamedTuple` with the process `process` removed from the `NamedTuple` `proc_vars`. - -# Example - -```julia -julia> drop_process((a = 1, b = 2, c = 3), :b) -(a = 1, c = 3) - -julia> drop_process((a = 1, b = 2, c = 3), (:a, :c)) -(b = 2,) -``` -""" -drop_process(proc_vars, process::Symbol) = Base.structdiff(proc_vars, NamedTuple{(process,)}) -drop_process(proc_vars, process) = Base.structdiff(proc_vars, NamedTuple{(process...,)}) - -""" - search_inputs_in_output(process, inputs, outputs) - -Return a dictionary with the soft dependencies of the processes in the dependency graph `d`. -A soft dependency is a dependency that is not explicitely defined in the model, but that -can be inferred from the inputs and outputs of the processes. - -# Arguments - -- `process::Symbol`: the process for which we want to find the soft dependencies. -- `inputs::Dict{Symbol, Vector{Pair{Symbol}, Tuple{Symbol, Vararg{Symbol}}}}`: a dict of process => symbols of inputs per process. -- `outputs::Dict{Symbol, Tuple{Symbol, Vararg{Symbol}}}`: a dict of process => symbols of outputs per process. - -# Details - -The inputs (and similarly, outputs) give the inputs of each process, classified by the process it comes from. It can -come from itself (its own inputs), or from another process that is a hard-dependency. - -# Returns - -A dictionary with the soft dependencies for the processes. - -# Example - -```julia -in_ = Dict( - :process3 => [:process3=>(:var4, :var5), :process2=>(:var1, :var3), :process1=>(:var1, :var2)], - :process4 => [:process4=>(:var0,)], - :process6 => [:process6=>(:var7, :var9)], - :process5 => [:process5=>(:var5, :var6)], -) - -out_ = Dict( - :process3 => Pair{Symbol}[:process3=>(:var4, :var6), :process2=>(:var4, :var5), :process1=>(:var3,)], - :process4 => [:process4=>(:var1, :var2)], - :process6 => [:process6=>(:var8,)], - :process5 => [:process5=>(:var7,)], -) - -search_inputs_in_output(:process3, in_, out_) -(process4 = (:var1, :var2),) -``` -""" -function search_inputs_in_output(process, inputs, outputs) - # proc, ins, outs - # get the inputs of the node: - vars_input = flatten_vars(inputs[process]) - - inputs_as_output_of_process = Dict() - for (proc_output, pairs_vars_output) in outputs # e.g. proc_output = :carbon_biomass; pairs_vars_output = outs[proc_output] - if process != proc_output - vars_output = flatten_vars(pairs_vars_output) - inputs_in_outputs = vars_in_variables(vars_input, vars_output) - - if any(inputs_in_outputs) - ins_in_outs = [vars_input...][inputs_in_outputs] - - # Remove the variables that are computed at the previous time step (used to break a cyclic dependency): - filter!(x -> !isa(x, MappedVar) || !isa(mapped_variable(x), PreviousTimeStep), ins_in_outs) - - # variables in the inputs of proc_input that are in the outputs of proc_output: - length(ins_in_outs) > 0 && push!(inputs_as_output_of_process, proc_output => Tuple(ins_in_outs)) - # Note: proc_output is the process that computes the inputs of proc_input - # These inputs are given by `vars_input[inputs_in_outputs]` - end - end - end - - return NamedTuple(inputs_as_output_of_process) -end - -function vars_in_variables(vars::T1, variables::T2) where {T1<:NamedTuple,T2<:NamedTuple} - [i in keys(variables) for i in keys(vars)] -end - -function vars_in_variables(vars, variables) - [i in variables for i in vars] -end - -""" - search_inputs_in_multiscale_output(process, organ, inputs, soft_dep_graphs) - -# Arguments - -- `process::Symbol`: the process for which we want to find the soft dependencies at other scales. -- `organ::Symbol`: the organ for which we want to find the soft dependencies. -- `inputs::Dict{Symbol, Vector{Pair{Symbol}, Tuple{Symbol, Vararg{Symbol}}}}`: a dict of process => [:subprocess => (:var1, :var2)]. -- `soft_dep_graphs::Dict{Symbol, ...}`: a dict of organ => (soft_dep_graph, inputs, outputs). -- `rev_mapping::Dict{Symbol, Symbol}`: a dict of mapped variable => source variable (this is the reverse mapping). -- 'hard_dependencies_from_other_scale' : a vector of HardDependencyNode to provide access to the hard dependencies without traversing the whole graph - -# Details - -The inputs (and similarly, outputs) give the inputs of each process, classified by the process it comes from. It can -come from itself (its own inputs), or from another process that is a hard-dependency. - -# Returns - -A dictionary with the soft dependencies variables found in outputs of other scales for each process, e.g.: - -```julia -Dict{Symbol, Dict{Symbol, Vector{Symbol}}} with 2 entries: - :Internode => Dict(:carbon_demand=>[:carbon_demand]) - :Leaf => Dict(:carbon_assimilation=>[:carbon_assimilation], :carbon_demand=>[:carbon_demand]) -``` - -This means that the variable `:carbon_demand` is computed by the process `:carbon_demand` at the scale `:Internode`, and the variable `:carbon_assimilation` -is computed by the process `:carbon_assimilation` at the scale `:Leaf`. Those variables are used as inputs for the process that we just passed. -""" -function search_inputs_in_multiscale_output(process, organ, inputs, soft_dep_graphs, rev_mapping, hard_dependencies_from_other_scale) - # proc, organ, ins, soft_dep_graphs=soft_dep_graphs_roots.roots - vars_input = flatten_vars(inputs[process]) - - inputs_as_output_of_other_scale = Dict{Symbol,Dict{Symbol,Vector{Symbol}}}() - for (var, val) in pairs(vars_input) # e.g. var = :leaf_surfaces;val = vars_input[var] - # The variable is a multiscale variable: - if isa(val, MappedVar) - var_organ = mapped_organ(val) - (isnothing(var_organ) || var_organ == Symbol("")) && continue # If the variable maps to nothing we skip it (e.g. [PreviousTimeStep(:var1)] or [:var => :new_var]) - if !isa(var_organ, AbstractVector) - # In case the organ is given as a singleton (e.g. :Soil instead of [:Soil]) - var_organ = [var_organ] - end - - @assert all(var_o != organ for var_o in var_organ) "$var in process $process is set to be multiscale, but points to its own scale ($organ). This is not allowed." - for org in var_organ # e.g. org = :Leaf - # The variable is a multiscale variable: - haskey(soft_dep_graphs, org) || error("Scale $org not found in the mapping, but mapped to the $organ scale.") - mapped_var = mapped_variable(val) - isa(mapped_var, PreviousTimeStep) && continue # Because we don't want to add the previous time step as a dependency - - # Avoid collecting variables at other scales if they come from a hard dependency - # They are handled internally by the hard dep, so if a hard dependency contains that variable, don't add it - # (This only needs to be done one level beneath the soft dependency nodes, any hard dependencies internal to another one don't expose their variables here) - - in_hard_dep::Bool = false - hd_os_current_scale = filter(x -> x.scale == org, hard_dependencies_from_other_scale) - for hd_os in hd_os_current_scale - hd_os_output_vars = [first(p) for p in pairs(hd_os.outputs)] - in_hard_dep |= length(filter(x -> x == var, hd_os_output_vars)) > 0 - end - !in_hard_dep && add_input_as_output!(inputs_as_output_of_other_scale, soft_dep_graphs, org, source_variable(val, org), mapped_var) - end - elseif isa(val, UninitializedVar) && haskey(rev_mapping, organ) - # The variable may be a variable written by another scale: - for (organ_source, proc_vars_dict) in rev_mapping[organ] - if haskey(proc_vars_dict, var) - add_input_as_output!(inputs_as_output_of_other_scale, soft_dep_graphs, organ_source, var, proc_vars_dict[var]) - end - end - end - end - - return inputs_as_output_of_other_scale -end - - -function add_input_as_output!(inputs_as_output_of_other_scale, soft_dep_graphs, organ_source, variable, value) - for (proc_output, pairs_vars_output) in soft_dep_graphs[organ_source][:outputs] # e.g. proc_output = :maintenance_respiration; pairs_vars_output = soft_dep_graphs_roots.roots[organ_source][:outputs][proc_output] - vars_output = flatten_vars(pairs_vars_output) - - # If the variable is found in the outputs of the process at the other scale: - if variable in keys(vars_output) - # The variable is found at another scale: - if haskey(inputs_as_output_of_other_scale, organ_source) - if haskey(inputs_as_output_of_other_scale[organ_source], proc_output) - push!(inputs_as_output_of_other_scale[organ_source][proc_output], value) - else - inputs_as_output_of_other_scale[organ_source][proc_output] = [value] - end - else - inputs_as_output_of_other_scale[organ_source] = Dict(proc_output => [value]) - end - end - end -end -""" - flatten_vars(vars) - -Return a set of the variables in the `vars` dictionary. - -# Arguments - -- `vars::Dict{Symbol, Tuple{Symbol, Vararg{Symbol}}}`: a dict of process => namedtuple of variables => value. - -# Returns - -A set of the variables in the `vars` dictionary. - -# Example - -```julia -julia> flatten_vars(Dict(:process1 => (:var1, :var2), :process2 => (:var3, :var4))) -Set{Symbol} with 4 elements: - :var4 - :var3 - :var2 - :var1 -``` - -```julia -julia> flatten_vars([:process1 => (var1 = -Inf, var2 = -Inf), :process2 => (var3 = -Inf, var4 = -Inf)]) -(var2 = -Inf, var4 = -Inf, var3 = -Inf, var1 = -Inf) -``` -""" -function flatten_vars(vars) - vars_input = Set() - for (key, val) in vars - flatten_vars(val, vars_input) - end - format_flatten((vars_input...,)) -end - -function flatten_vars(val::NamedTuple, vars_input::Set) - for (k, j) in pairs(val) - push!(vars_input, k => j) - end -end - -function flatten_vars(val::Tuple, vars_input::Set) - for j in val - push!(vars_input, j) - end -end - -format_flatten(vars::Tuple{Vararg{Pair}}) = NamedTuple(vars) -format_flatten(vars) = vars diff --git a/src/dependencies/traversal.jl b/src/dependencies/traversal.jl deleted file mode 100644 index d61a0133f..000000000 --- a/src/dependencies/traversal.jl +++ /dev/null @@ -1,174 +0,0 @@ -""" - traverse_dependency_graph(graph::DependencyGraph, f::Function; visit_hard_dep=true) - traverse_dependency_graph(graph; visit_hard_dep=true) - -Traverse the dependency `graph` and apply the function `f` to each node, or just return the nodes if `f` is not provided. - -The first-level soft-dependencies are traversed first, then their hard-dependencies (if `visit_hard_dep=true`), and then -the children of the soft-dependencies. - -Return a vector of pairs of the node and the result of the function `f`. - -# Example - -```julia -using PlantSimEngine - -# Including example processes and models: -using PlantSimEngine.Examples; - -function f(node) - node.value -end - -vars = ( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - process4=Process4Model(), - process5=Process5Model(), - process6=Process6Model(), - process7=Process7Model(), -) - -graph = dep(vars) -traverse_dependency_graph(graph, f) -``` -""" -function traverse_dependency_graph( - graph::DependencyGraph, - f::Function; - visit_hard_dep=true -) - - var = Pair{Symbol,Any}[] - nodes_types = visit_hard_dep ? AbstractDependencyNode : SoftDependencyNode - node_visited = Set{nodes_types}() - for (p, root) in graph.roots - traverse_dependency_graph!(root, f, var; visit_hard_dep=visit_hard_dep, node_visited=node_visited) - end - - return var -end - -function traverse_dependency_graph( - graph::DependencyGraph, - visit_hard_dep=true -) - nodes_types = visit_hard_dep ? AbstractDependencyNode : SoftDependencyNode - var = Pair{Symbol,nodes_types}[] - node_visited = Set{nodes_types}() - for (p, root) in graph.roots - traverse_dependency_graph!(root, x -> x, var; visit_hard_dep=visit_hard_dep, node_visited=node_visited) - end - - return last.(var) -end - -function traverse_dependency_graph!(f::Function, graph::DependencyGraph; visit_hard_dep=true, node_visited::Set=Set{AbstractDependencyNode}()) - for (p, root) in graph.roots - traverse_dependency_graph!(f, root, visit_hard_dep=visit_hard_dep, node_visited=node_visited) - end -end - -function traverse_dependency_graph!(f::Function, node::SoftDependencyNode; visit_hard_dep=true, node_visited::Set=Set{AbstractDependencyNode}()) - if node in node_visited - return nothing - end - - f(node) - - push!(node_visited, node) - - # Traverse the hard dependencies of the SoftDependencyNode if any: - if visit_hard_dep && node isa SoftDependencyNode - # draw a branching guide if there's more soft dependencies after this one: - for child in node.hard_dependency - traverse_dependency_graph!(f, child; visit_hard_dep=visit_hard_dep, node_visited=node_visited) - end - end - - for child in node.children - traverse_dependency_graph!(f, child; visit_hard_dep=visit_hard_dep, node_visited=node_visited) - end -end - -function traverse_dependency_graph!(f::Function, node::HardDependencyNode; visit_hard_dep=true, node_visited::Set=Set{AbstractDependencyNode}()) - if node in node_visited - return nothing - end - - f(node) - - push!(node_visited, node) - - # Traverse all hard dependencies: - for child in node.children - traverse_dependency_graph!(f, child; visit_hard_dep=visit_hard_dep, node_visited=node_visited) - end -end - - -""" - traverse_dependency_graph(node::SoftDependencyNode, f::Function, var::Vector; visit_hard_dep=true) - -Apply function `f` to `node`, visit its hard dependency nodes (if `visit_hard_dep=true`), and -then its soft dependency children. - -Mutate the vector `var` by pushing a pair of the node process name and the result of the function `f`. -""" -function traverse_dependency_graph!( - node::SoftDependencyNode, - f::Function, - var::Vector; - visit_hard_dep=true, - node_visited::Set=Set{AbstractDependencyNode}() -) - if node in node_visited - return nothing - end - - push!(var, node.process => f(node)) - - push!(node_visited, node) - - # Traverse the hard dependencies of the SoftDependencyNode if any: - if visit_hard_dep && node isa SoftDependencyNode - # draw a branching guide if there's more soft dependencies after this one: - for child in node.hard_dependency - traverse_dependency_graph!(child, f, var; visit_hard_dep=visit_hard_dep, node_visited=node_visited) - end - end - - for child in node.children - traverse_dependency_graph!(child, f, var; visit_hard_dep=visit_hard_dep, node_visited=node_visited) - end -end - -""" - traverse_dependency_graph(node::HardDependencyNode, f::Function, var::Vector) - -Apply function `f` to `node`, and then its children (hard-dependency nodes). - -Mutate the vector `var` by pushing a pair of the node process name and the result of the function `f`. -""" -function traverse_dependency_graph!( - node::HardDependencyNode, - f::Function, - var::Vector; - visit_hard_dep=true, # Just to be compatible with a call shared with SoftDependencyNode method - node_visited::Set=Set{HardDependencyNode}() -) - - if node in node_visited - return nothing - end - - push!(var, node.process => f(node)) - - push!(node_visited, node) - - for child in node.children - traverse_dependency_graph!(child, f, var; visit_hard_dep=visit_hard_dep, node_visited=node_visited) - end -end diff --git a/src/doc_templates/mtg-related.jl b/src/doc_templates/mtg-related.jl deleted file mode 100644 index 352b4bb8e..000000000 --- a/src/doc_templates/mtg-related.jl +++ /dev/null @@ -1,67 +0,0 @@ -const MTG_EXAMPLE = """ -```@example -mtg = Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)); -``` - -```@example -soil = Node(mtg, MultiScaleTreeGraph.NodeMTG("/", :Soil, 1, 1)); -``` - -```@example -plant = Node(mtg, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)); -``` - -```@example -internode1 = Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)); -``` - -```@example -leaf1 = Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)); -``` - -```@example -internode2 = Node(internode1, MultiScaleTreeGraph.NodeMTG("<", :Internode, 1, 2)); -``` - -```@example -leaf2 = Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)); -``` -""" - -const MAPPING_EXAMPLE = """ -```@example -mapping = ModelMapping( \ - :Plant => ( \ - MultiScaleModel( \ - model=ToyCAllocationModel(), \ - mapped_variables=[ \ - :carbon_assimilation => [:Leaf], \ - :carbon_demand => [:Leaf, :Internode], \ - :carbon_allocation => [:Leaf, :Internode] \ - ], \ - ), - MultiScaleModel( \ - model=ToyPlantRmModel(), \ - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],] \ - ), \ - ),\ - :Internode => ( \ - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), \ - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), \ - Status(TT=10.0) \ - ), \ - :Leaf => ( \ - MultiScaleModel( \ - model=ToyAssimModel(), \ - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], \ - ), \ - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), \ - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), \ - Status(aPPFD=1300.0, TT=10.0), \ - ), \ - :Soil => ( \ - ToySoilWaterModel(), \ - ), \ -) -``` -""" diff --git a/src/evaluation/fit.jl b/src/evaluation/fit.jl index 7998d6a5d..65deda86d 100644 --- a/src/evaluation/fit.jl +++ b/src/evaluation/fit.jl @@ -29,10 +29,28 @@ and `Ri_PAR_f`. # Including example processes and models: using PlantSimEngine.Examples; -m = ModelList(Beer(0.6), status=(LAI=2.0,)) -meteo = Atmosphere(T=20.0, Wind=1.0, P=101.3, Rh=0.65, Ri_PAR_f=300.0) -run!(m, meteo) -df = DataFrame(aPPFD=m[:aPPFD][1], LAI=m.status.LAI[1], Ri_PAR_f=meteo.Ri_PAR_f[1]) +meteo = Atmosphere( + T=20.0, + Wind=1.0, + P=101.3, + Rh=0.65, + Ri_PAR_f=300.0, + duration=Hour(1), +) +model = CompositeModel( + Beer(0.6); + status=(LAI=2.0,), + id=:leaf, + scale=:Leaf, + environment=meteo, +) +run!(model) +leaf = only(model_objects(model; scale=:Leaf)) +df = DataFrame( + aPPFD=leaf.status.aPPFD, + LAI=leaf.status.LAI, + Ri_PAR_f=meteo.Ri_PAR_f[1], +) fit(Beer, df) ``` @@ -40,4 +58,4 @@ Note that this is a dummy example to show that the fitting method works, as we s using the Beer-Lambert law with a value of `k=0.6`, and then use the simulated aPPFD to fit the `k` parameter again, which gives the same value as the one used on the simulation. """ -function fit end \ No newline at end of file +function fit end diff --git a/src/examples_import.jl b/src/examples_import.jl index be89fc4d3..0dee9f0fd 100644 --- a/src/examples_import.jl +++ b/src/examples_import.jl @@ -33,7 +33,6 @@ include(joinpath(@__DIR__, "../examples/ToyRUEGrowthModel.jl")) include(joinpath(@__DIR__, "../examples/ToyCAllocationModel.jl")) include(joinpath(@__DIR__, "../examples/ToyMaintenanceRespirationModel.jl")) include(joinpath(@__DIR__, "../examples/ToySoilModel.jl")) -include(joinpath(@__DIR__, "../examples/ToyInternodeEmergence.jl")) include(joinpath(@__DIR__, "../examples/ToyCBiomassModel.jl")) include(joinpath(@__DIR__, "../examples/ToyLeafSurfaceModel.jl")) include(joinpath(@__DIR__, "../examples/ToyLightPartitioningModel.jl")) @@ -42,7 +41,7 @@ include(joinpath(@__DIR__, "../examples/ToyLightPartitioningModel.jl")) """ import_mtg_example() -Returns an example multiscale tree graph (MTG) with a scene, a soil, and a plant with two internodes and two leaves. +Returns an example multiscale tree graph (MTG) with a model, a soil, and a plant with two internodes and two leaves. # Examples @@ -82,7 +81,6 @@ export AbstractLai_DynamicModel, AbstractLeaf_SurfaceModel export AbstractDegreedaysModel export AbstractCarbon_AssimilationModel, AbstractCarbon_AllocationModel, AbstractCarbon_DemandModel, AbstractCarbon_BiomassModel export AbstractSoil_WaterModel, AbstractGrowthModel -export AbstractOrgan_EmergenceModel export AbstractMaintenance_RespirationModel # Models: @@ -92,6 +90,5 @@ export ToyAssimGrowthModel, ToyRUEGrowthModel, ToyMaintenanceRespirationModel, T export Process1Model, Process2Model, Process3Model, Process4Model, Process5Model export Process6Model, Process7Model -export ToyInternodeEmergence export import_mtg_example -end \ No newline at end of file +end diff --git a/src/model_discovery.jl b/src/model_discovery.jl new file mode 100644 index 000000000..f483a5e10 --- /dev/null +++ b/src/model_discovery.jl @@ -0,0 +1,307 @@ +const _MODEL_PARAMETER_TYPE_CHOICES = ( + :float, + :integer, + :boolean, + :symbol, + :string, + :nothing, + :julia, +) + +const _MODEL_DISCOVERY_EXCLUDED_NAMES = Set{Symbol}([ + :ObjectModelOverrides, + :ModelSpec, +]) + +""" + available_processes() + +Return process abstract model types visible in the current Julia session. +Loading another package with `using PackageName` makes its process and model +types discoverable without a separate registry. +""" +function available_processes() + processes = Type[] + for type in _abstract_model_subtypes() + _is_process_type(type) || continue + push!(processes, type) + end + return sort!(unique(processes); by=type -> string(process_(type))) +end + +""" + available_models() + available_models(process::Symbol) + available_models(process_type::Type{<:AbstractModel}) + +Return concrete model implementation types visible in the current Julia +session. +""" +function available_models() + models = Type[] + for type in _abstract_model_subtypes() + _is_available_model_type(type) || continue + push!(models, type) + end + return sort!(unique(models); by=type -> string(_process_name_for_type(type), ".", nameof(type))) +end + +available_models(process_name::Symbol) = + filter(type -> _process_name_for_type(type) == process_name, available_models()) + +function available_models(process_type::Type{<:AbstractModel}) + process_name = _is_process_type(process_type) ? + process_(process_type) : + _process_name_for_type(process_type) + return available_models(process_name) +end + +""" + model_descriptor(::Type{<:AbstractModel}) + +Return JSON-compatible discovery metadata for a model implementation type. +Input and output metadata is inferred best-effort from a zero-argument or dummy +instance when the type can be constructed safely. +""" +function model_descriptor(::Type{T}) where {T<:AbstractModel} + process_type = _process_type_for_model(T) + process_name = isnothing(process_type) ? nothing : process_(process_type) + module_ = parentmodule(T) + return Dict{String,Any}( + "type" => string(T), + "name" => string(nameof(T)), + "module" => string(module_), + "package" => _model_package_name(module_), + "process" => isnothing(process_name) ? nothing : string(process_name), + "processType" => isnothing(process_type) ? nothing : string(process_type), + "inputs" => _model_var_descriptor(T, inputs_), + "outputs" => _model_var_descriptor(T, outputs_), + "environmentInputs" => _model_var_descriptor(T, meteo_inputs_), + "environmentOutputs" => _model_var_descriptor(T, meteo_outputs_), + "timespec" => _safe_string_trait(T, timespec), + "outputPolicy" => _safe_string_trait(T, output_policy), + "timestepHint" => _safe_string_trait(T, timestep_hint), + "meteoHint" => _safe_string_trait(T, meteo_hint), + "constructor" => model_constructor_descriptor(T), + ) +end + +""" + model_constructor_descriptor(::Type{<:AbstractModel}) + +Return constructor metadata inferred from struct fields and an optional +zero-argument constructor. Fields that share a type parameter share a type +choice in the editor. +""" +function model_constructor_descriptor(::Type{T}) where {T<:AbstractModel} + unwrapped_type = Base.unwrap_unionall(T) + names = collect(fieldnames(unwrapped_type)) + declared_types = collect(fieldtypes(unwrapped_type)) + default_instance = _try_zero_arg_model(T) + has_defaults = !isnothing(default_instance) + positional_values = _dummy_constructor_values(declared_types) + positional_constructible = !isnothing(positional_values) && + _can_construct_with(T, positional_values) + + fields = Dict{String,Any}[] + parameter_groups = Dict{String,Vector{String}}() + for (index, name) in pairs(names) + declared = declared_types[index] + default = has_defaults ? getfield(default_instance, name) : nothing + default_type = has_defaults ? typeof(default) : nothing + parameter_key = _field_type_parameter_key(declared) + field_name = string(name) + if !isnothing(parameter_key) + push!(get!(parameter_groups, parameter_key, String[]), field_name) + end + push!(fields, Dict{String,Any}( + "name" => field_name, + "declaredType" => string(declared), + "hasDefault" => has_defaults, + "default" => has_defaults ? _jsonable_model_value(default) : nothing, + "defaultJulia" => has_defaults ? repr(default) : nothing, + "defaultType" => isnothing(default_type) ? nothing : string(default_type), + "typeParameter" => parameter_key, + "inferredChoice" => string(_parameter_choice(default_type, declared)), + "choices" => string.(_MODEL_PARAMETER_TYPE_CHOICES), + )) + end + + return Dict{String,Any}( + "type" => string(T), + "name" => string(nameof(T)), + "fields" => fields, + "parameterGroups" => parameter_groups, + "hasZeroArgConstructor" => has_defaults, + "constructible" => has_defaults || positional_constructible, + "positional" => true, + "keyword" => false, + ) +end + +function _abstract_model_subtypes(root::Type=AbstractModel, seen=Set{Type}()) + found = Type[] + root in seen && return found + push!(seen, root) + for child in InteractiveUtils.subtypes(root) + child in seen && continue + push!(found, child) + append!(found, _abstract_model_subtypes(child, seen)) + end + return found +end + +function _is_process_type(type::Type) + isabstracttype(type) || return false + type === AbstractModel && return false + try + process_(type) + return true + catch + return false + end +end + +function _is_available_model_type(type::Type) + isabstracttype(type) && return false + nameof(type) in _MODEL_DISCOVERY_EXCLUDED_NAMES && return false + return !isnothing(_process_type_for_model(type)) +end + +function _process_type_for_model(type::Type) + current = type + while current !== Any && current !== AbstractModel + _is_process_type(current) && return current + current = supertype(current) + end + return nothing +end + +function _process_name_for_type(type::Type) + process_type = _process_type_for_model(type) + return isnothing(process_type) ? Symbol(nameof(type)) : process_(process_type) +end + +function _model_var_descriptor(::Type{T}, accessor) where {T<:AbstractModel} + instance = _try_zero_arg_model(T) + isnothing(instance) && (instance = _try_dummy_model(T)) + isnothing(instance) && return Dict{String,Any}() + variables = try + accessor(instance) + catch err + return Dict{String,Any}("_error" => sprint(showerror, err)) + end + return Dict(string(name) => _jsonable_model_value(value) for (name, value) in pairs(variables)) +end + +function _safe_string_trait(::Type{T}, trait) where {T<:AbstractModel} + value = try + trait(T) + catch + instance = _try_zero_arg_model(T) + isnothing(instance) && return nothing + try + trait(instance) + catch err + return string("error: ", sprint(showerror, err)) + end + end + return string(value) +end + +function _try_zero_arg_model(::Type{T}) where {T<:AbstractModel} + try + return T() + catch + return nothing + end +end + +function _try_dummy_model(::Type{T}) where {T<:AbstractModel} + unwrapped_type = Base.unwrap_unionall(T) + names = fieldnames(unwrapped_type) + isempty(names) && return nothing + values = _dummy_constructor_values(fieldtypes(unwrapped_type)) + isnothing(values) && return nothing + try + return T(values...) + catch + return nothing + end +end + +function _dummy_constructor_values(field_types) + values = Any[] + for field_type in field_types + value = _dummy_field_value(field_type) + isnothing(value) && return nothing + push!(values, value) + end + return values +end + +function _can_construct_with(::Type{T}, values) where {T<:AbstractModel} + try + T(values...) + return true + catch + return false + end +end + +_dummy_field_value(::TypeVar) = 0.0 + +function _dummy_field_value(type) + try + type === Any && return 0.0 + type === Bool && return false + type <: Integer && return zero(type) + type <: AbstractFloat && return zero(type) + type <: Real && return zero(type) + type <: Symbol && return :value + type <: AbstractString && return "" + catch + return nothing + end + return nothing +end + +_field_type_parameter_key(field_type) = field_type isa TypeVar ? string(field_type.name) : nothing + +function _parameter_choice(default_type, declared_type) + !isnothing(default_type) && return _parameter_choice_from_type(default_type) + return _parameter_choice_from_type(declared_type) +end + +function _parameter_choice_from_type(type) + try + type === Nothing && return :nothing + type === Any && return :float + type isa TypeVar && return :float + type === Bool && return :boolean + type <: Integer && return :integer + type <: AbstractFloat && return :float + type <: Real && return :float + type <: Symbol && return :symbol + type <: AbstractString && return :string + catch + return :float + end + return :julia +end + +function _jsonable_model_value(value) + value === nothing && return nothing + value isa Bool && return value + value isa Real && isfinite(value) && return value + value isa Symbol && return string(":", value) + value isa AbstractString && return value + value isa AbstractArray && return string(typeof(value), " length ", length(value)) + return string(value) +end + +function _model_package_name(module_::Module) + root = Base.moduleroot(module_) + root in (Base, Core, Main) && return nothing + return string(nameof(root)) +end diff --git a/src/mtg/GraphSimulation.jl b/src/mtg/GraphSimulation.jl deleted file mode 100644 index 8b826680d..000000000 --- a/src/mtg/GraphSimulation.jl +++ /dev/null @@ -1,157 +0,0 @@ -""" - GraphSimulation(graph, mapping) - GraphSimulation(graph, statuses, dependency_graph, models, outputs) - -A type that holds all information for a simulation over a graph. - -# Arguments - -- `graph`: an graph, such as an MTG -- `mapping`: a dictionary of model mapping -- `statuses`: a structure that defines the status of each node in the graph -- `status_templates`: a dictionary of status templates -- `reverse_multiscale_mapping`: a dictionary of mapping for other scales -- `var_need_init`: a dictionary indicating if a variable needs to be initialized -- `dependency_graph`: the dependency graph of the models applied to the graph -- `models`: a dictionary of models -- `model_specs`: a dictionary of normalized model usage specifications -- `outputs`: a dictionary of outputs -- `temporal_state`: multi-rate temporal storage used at runtime for producer streams, - input resolution caches, run clocks, and requested output export buffers -""" -struct GraphSimulation{T,S,U,O,V,TS,MS} - graph::T - statuses::S - status_templates::Dict{Symbol,Dict{Symbol,Any}} - reverse_multiscale_mapping::Dict{Symbol,Dict{Symbol,Dict{Symbol,Any}}} - var_need_init::Dict{Symbol,V} - dependency_graph::DependencyGraph - models::Dict{Symbol,U} - model_specs::MS - outputs::Dict{Symbol,O} - outputs_index::Dict{Symbol,Int} - temporal_state::TS - is_multirate::Bool -end - -function GraphSimulation(graph, mapping; nsteps=1, outputs=nothing, type_promotion=_type_promotion(mapping), check=true, verbose=false) - mapping_checked = mapping isa ModelMapping ? mapping : ModelMapping(mapping) - GraphSimulation(init_simulation(graph, mapping_checked; nsteps=nsteps, outputs=outputs, type_promotion=type_promotion, check=check, verbose=verbose)...) -end - -dep(g::GraphSimulation) = g.dependency_graph -status(g::GraphSimulation) = g.statuses -status_template(g::GraphSimulation) = g.status_templates -reverse_mapping(g::GraphSimulation) = g.reverse_multiscale_mapping -var_need_init(g::GraphSimulation) = g.var_need_init -get_models(g::GraphSimulation) = g.models -get_model_specs(g::GraphSimulation) = g.model_specs -outputs(g::GraphSimulation) = g.outputs -temporal_state(g::GraphSimulation) = g.temporal_state -is_multirate(g::GraphSimulation) = g.is_multirate - -""" - convert_outputs(sim_outputs::Dict{Symbol,O} where O, sink; refvectors=false, no_value=nothing) - convert_outputs(sim_outputs::TimeStepTable{T} where T, sink) - -Convert the outputs returned by a simulation made on a plant graph into another format. - -# Details - -The first method operates on the outputs of a multiscale simulation, the second one on those of a typical single-scale simulation. -The sink function determines the format used, for exemple a `DataFrame`. - -# Arguments - -- `sim_outputs : the outputs of a prior simulation, typically returned by `run!`. -- `sink`: a sink compatible with the Tables.jl interface (*e.g.* a `DataFrame`) -- `refvectors`: if `false` (default), the function will remove the RefVector values, otherwise it will keep them -- `no_value`: the value to replace `nothing` values. Default is `nothing`. Usually used to replace `nothing` values -by `missing` in DataFrames. - -# Examples - -```@example -using PlantSimEngine, MultiScaleTreeGraph, DataFrames, PlantSimEngine.Examples -``` - -Import example models (can be found in the `examples` folder of the package, or in the `Examples` sub-modules): - -```jldoctest mylabel -julia> using PlantSimEngine.Examples; -``` - -$MAPPING_EXAMPLE - -```@example -mtg = import_mtg_example(); -``` - -```@example -out = run!(mtg, mapping, meteo, tracked_outputs = Dict( - :Leaf => (:carbon_assimilation, :carbon_demand, :soil_water_content, :carbon_allocation), - :Internode => (:carbon_allocation,), - :Plant => (:carbon_allocation,), - :Soil => (:soil_water_content,), -)); -``` - -```@example -convert_outputs(out, DataFrames) -``` -""" -# Another, possibly better way would be to just create the DataFrame directly from the outputs -# and then remove the RefVector columns and replace the node one, hmm -function convert_outputs(outs::Dict{Symbol,O} where O, sink; refvectors=false, no_value=nothing) - ret = Dict{Symbol,sink}() - for (organ, status_vector) in outs - # remove RefVector variables - refv = () - if length(status_vector) > 0 - for (var, val) in pairs(status_vector[1]) - if !refvectors && isa(val, RefVector) - refv = (refv..., var) - end - if var == :node - refv = (refv..., var) - end - end - else - @warn "No instance found at the $organ scale, no output available, removing it from the Dict" - continue - end - - # Get the new NamedTuple type - refv_nt = NamedTuple{refv} - - # Piddle around with the first element to get the final type to be able to allocate the exact vector size with a definite element type - vector_named_tuple_1 = NamedTuple(status_vector[1]) - - # replace the MTG node var with the id (MTG nodes aren't CSV-friendly) - filtered_named_tuple = (; node=MultiScaleTreeGraph.node_id(vector_named_tuple_1.node), Base.structdiff(vector_named_tuple_1, refv_nt)...) - filtered_vector_named_tuple = Vector{typeof(filtered_named_tuple)}(undef, length(status_vector)) - - for i in 1:length(status_vector) - vector_named_tuple_i = NamedTuple(status_vector[i]) - filtered_vector_named_tuple[i] = (; node=MultiScaleTreeGraph.node_id(vector_named_tuple_i.node), Base.structdiff(vector_named_tuple_i, refv_nt)...) - end - - ret[organ] = sink(filtered_vector_named_tuple) - end - return ret -end - -# TODO adapt these to new output structure or remove them -function outputs(outs::Dict{Symbol,O} where O, key::Symbol) - Tables.columns(convert_outputs(outs, Vector{NamedTuple}))[key] -end - -function outputs(outs::Dict{Symbol,O} where O, i::T) where {T<:Integer} - Tables.columns(convert_outputs(outs, Vector{NamedTuple}))[i] -end - -# ModelLists now return outputs as a TimeStepTable{Status}, conversion is straightforward -function convert_outputs(out::TimeStepTable{T} where T, sink) - @assert Tables.istable(sink) "The sink argument must be compatible with the Tables.jl interface (`Tables.istable(sink)` must return `true`, *e.g.* `DataFrame`)" - return sink(out) -end diff --git a/src/mtg/ModelSpec.jl b/src/mtg/ModelSpec.jl deleted file mode 100644 index f461515ec..000000000 --- a/src/mtg/ModelSpec.jl +++ /dev/null @@ -1,410 +0,0 @@ -""" - ModelSpec(model; multiscale=nothing, timestep=nothing, input_bindings=NamedTuple(), meteo_bindings=NamedTuple(), meteo_window=nothing, output_routing=NamedTuple(), scope=:global) - -User-side model configuration wrapper for mapping/model list composition. - -`ModelSpec` keeps model implementation and scenario-specific usage metadata in one place. -This allows modelers to publish reusable models while users decide how models are coupled in -their simulation setup. -""" -struct ModelSpec{M,MS,TS,IB,MB,MW,OR,SC} - model::M - multiscale::MS - timestep::TS - input_bindings::IB - meteo_bindings::MB - meteo_window::MW - output_routing::OR - scope::SC -end - -function _normalize_multiscale_mapping(model::AbstractModel, mapped_variables) - mapped_variables === nothing && return nothing - mapped = MultiScaleModel(model, mapped_variables) - return mapped_variables_(mapped) -end - -function ModelSpec( - model::AbstractModel; - multiscale=nothing, - timestep=nothing, - input_bindings=NamedTuple(), - meteo_bindings=NamedTuple(), - meteo_window=nothing, - output_routing=NamedTuple(), - scope=:global -) - base_model = model - base_multiscale = multiscale - - if model isa MultiScaleModel - base_model = model_(model) - base_multiscale === nothing && (base_multiscale = mapped_variables_(model)) - end - - normalized_multiscale = _normalize_multiscale_mapping(base_model, base_multiscale) - normalized_input_bindings = _normalize_input_bindings(input_bindings) - normalized_meteo_bindings = _normalize_meteo_bindings(meteo_bindings) - normalized_meteo_window = _normalize_meteo_window(meteo_window) - normalized_output_routing = _normalize_output_routing(output_routing) - normalized_scope = _normalize_scope_selector(scope) - return ModelSpec{typeof(base_model),typeof(normalized_multiscale),typeof(timestep),typeof(normalized_input_bindings),typeof(normalized_meteo_bindings),typeof(normalized_meteo_window),typeof(normalized_output_routing),typeof(normalized_scope)}( - base_model, - normalized_multiscale, - timestep, - normalized_input_bindings, - normalized_meteo_bindings, - normalized_meteo_window, - normalized_output_routing, - normalized_scope - ) -end - -function ModelSpec( - spec::ModelSpec; - model=spec.model, - multiscale=spec.multiscale, - timestep=spec.timestep, - input_bindings=spec.input_bindings, - meteo_bindings=spec.meteo_bindings, - meteo_window=spec.meteo_window, - output_routing=spec.output_routing, - scope=spec.scope -) - ModelSpec(model; multiscale=multiscale, timestep=timestep, input_bindings=input_bindings, meteo_bindings=meteo_bindings, meteo_window=meteo_window, output_routing=output_routing, scope=scope) -end - -as_model_spec(spec::ModelSpec) = spec -as_model_spec(model::AbstractModel) = ModelSpec(model) -as_model_spec(model::MultiScaleModel) = ModelSpec(model_(model); multiscale=mapped_variables_(model)) - -""" - with_multiscale(model_or_spec, mapped_variables) - -Return a `ModelSpec` with updated multiscale mapping. -""" -function with_multiscale(model_or_spec, mapped_variables) - spec = as_model_spec(model_or_spec) - return ModelSpec(spec; multiscale=mapped_variables) -end - -""" - with_timestep(model_or_spec, timestep) - -Return a `ModelSpec` with an explicit user-selected timestep. -""" -function with_timestep(model_or_spec, timestep) - spec = as_model_spec(model_or_spec) - return ModelSpec(spec; timestep=timestep) -end - -""" - with_input_bindings(model_or_spec, bindings) - -Return a `ModelSpec` with explicit user-defined input-to-producer bindings. -""" -function with_input_bindings(model_or_spec, bindings) - spec = as_model_spec(model_or_spec) - return ModelSpec(spec; input_bindings=_normalize_input_bindings(bindings)) -end - -""" - with_meteo_bindings(model_or_spec, bindings) - -Return a `ModelSpec` with explicit meteo aggregation bindings. -""" -function with_meteo_bindings(model_or_spec, bindings) - spec = as_model_spec(model_or_spec) - return ModelSpec(spec; meteo_bindings=_normalize_meteo_bindings(bindings)) -end - -""" - with_meteo_window(model_or_spec, window) - -Return a `ModelSpec` with explicit weather-window selection strategy. -""" -function with_meteo_window(model_or_spec, window) - spec = as_model_spec(model_or_spec) - return ModelSpec(spec; meteo_window=_normalize_meteo_window(window)) -end - -""" - with_output_routing(model_or_spec, routing) - -Return a `ModelSpec` with explicit user-defined output routing. -""" -function with_output_routing(model_or_spec, routing) - spec = as_model_spec(model_or_spec) - return ModelSpec(spec; output_routing=_normalize_output_routing(routing)) -end - -""" - with_scope(model_or_spec, scope) - -Return a `ModelSpec` with explicit scope selection for multi-rate stream keys. -""" -function with_scope(model_or_spec, scope) - spec = as_model_spec(model_or_spec) - return ModelSpec(spec; scope=_normalize_scope_selector(scope)) -end - -function _normalize_input_binding(binding) - if binding isa NamedTuple - return haskey(binding, :policy) ? binding : (; binding..., policy=HoldLast()) - elseif binding isa Pair{Symbol,Symbol} - return (process=first(binding), var=last(binding), policy=HoldLast()) - elseif binding isa Symbol - return (process=binding, policy=HoldLast()) - end - return binding -end - -function _normalize_input_bindings(bindings::NamedTuple) - normalized = Pair{Symbol,Any}[] - for (k, v) in pairs(bindings) - push!(normalized, k => _normalize_input_binding(v)) - end - return (; normalized...) -end - -_normalize_input_bindings(bindings) = bindings - -function _normalize_meteo_binding(binding) - if binding isa DataType - binding <: PlantMeteo.AbstractTimeReducer || error( - "Unsupported MeteoBindings reducer type `$(binding)`. ", - "Use a PlantMeteo reducer type/instance, callable, or NamedTuple(source=..., reducer=...)." - ) - return binding - elseif binding isa PlantMeteo.AbstractTimeReducer - return binding - elseif binding isa Function - return binding - elseif binding isa NamedTuple - return binding - end - error( - "Unsupported MeteoBindings value `$(binding)` of type `$(typeof(binding))`. ", - "Use a PlantMeteo reducer type/instance, callable, or NamedTuple(source=..., reducer=...)." - ) -end - -function _normalize_meteo_bindings(bindings::NamedTuple) - normalized = Pair{Symbol,Any}[] - for (k, v) in pairs(bindings) - push!(normalized, k => _normalize_meteo_binding(v)) - end - return (; normalized...) -end - -_normalize_meteo_bindings(bindings) = bindings - -function _normalize_meteo_window(window) - if isnothing(window) - return nothing - elseif window isa DataType - window <: PlantMeteo.AbstractSamplingWindow || error( - "Unsupported MeteoWindow type `$(window)`. ", - "Use a PlantMeteo sampling-window type/instance." - ) - return window() - elseif window isa PlantMeteo.AbstractSamplingWindow - return window - end - - error( - "Unsupported MeteoWindow value `$(window)` of type `$(typeof(window))`. ", - "Use a PlantMeteo sampling-window type/instance." - ) -end - -function _normalize_output_routing(routing::NamedTuple) - normalized = Pair{Symbol,Symbol}[] - for (k, v) in pairs(routing) - mode = Symbol(v) - mode in (:canonical, :stream_only) || error( - "Unsupported output routing mode `$(mode)` for output `$(k)`. ", - "Allowed values are `:canonical` and `:stream_only`." - ) - push!(normalized, k => mode) - end - return (; normalized...) -end - -_normalize_output_routing(routing) = routing - -function _normalize_scope_selector(scope) - if scope isa AbstractString - return Symbol(scope) - end - return scope -end - -""" - MultiScaleModel(mapped_variables) - -Pipe-style transform that updates multiscale mapping on a model/spec. -""" -MultiScaleModel(mapped_variables) = x -> with_multiscale(x, mapped_variables) - -""" - TimeStepModel(timestep) - -Pipe-style transform that sets a user-selected timestep on a model/spec. -""" -TimeStepModel(timestep) = x -> with_timestep(x, timestep) - -""" - InputBindings(bindings) - InputBindings(; kwargs...) - -Pipe-style transform that sets explicit producer bindings for model inputs. - -This is used in multi-rate mappings to tell runtime where each input should be -read from (process, optional source variable, optional source scale, and policy). - -# Arguments -- `bindings::NamedTuple`: maps each consumer input variable (`Symbol`) to a - binding descriptor. -- `kwargs...`: keyword shorthand equivalent to a `NamedTuple`. - -Each binding descriptor can be: -- `Symbol`: producer process (`policy=HoldLast()` and source variable inferred). -- `Pair{Symbol,Symbol}`: `producer_process => source_var` - (`policy=HoldLast()`). -- `NamedTuple`: explicit fields: - - `process` (`Symbol`/`String`, optional if uniquely inferable), - - `var` (`Symbol`, optional, defaults to same-name input when inferable), - - `scale` (`String`/`Symbol`, optional, useful for cross-scale disambiguation), - - `policy` (`SchedulePolicy` instance/type, optional, default `HoldLast()`). - -When omitted fields cannot be inferred uniquely, runtime errors and asks for an -explicit `InputBindings(...)`. - -# Example -```julia -ModelSpec(ConsumerModel()) |> -TimeStepModel(ClockSpec(24.0, 0.0)) |> -InputBindings(; A=(process=:assim, var=:carbon_assimilation, scale=:Leaf, policy=Integrate())) -``` -""" -InputBindings(bindings) = x -> with_input_bindings(x, bindings) -InputBindings(; kwargs...) = InputBindings((; kwargs...)) - -""" - MeteoBindings(bindings) - MeteoBindings(; kwargs...) - -Pipe-style transform that sets weather-variable aggregation rules per model. - -Each key is the target weather variable name as seen by the model (for example -`:T`, `:Rh`, `:Ri_SW_q`). - -# Arguments -- `bindings::NamedTuple`: per-target meteo binding rules. -- `kwargs...`: keyword shorthand equivalent to a `NamedTuple`. - -Each rule value can be: -- a `PlantMeteo.AbstractTimeReducer` instance/type - (for example `MeanWeighted()`, `MaxReducer`, `RadiationEnergy()`), -- a callable reducer (`Function`) receiving sampled values, -- a `NamedTuple` with: - - `source` (`Symbol`/`String`, optional, defaults to target key), - - `reducer` (reducer type/instance/callable, optional, defaults to - `MeanWeighted()`). - -# Example -```julia -ModelSpec(DailyModel()) |> -TimeStepModel(ClockSpec(24.0, 0.0)) |> -MeteoBindings( - ; - T=MeanWeighted(), - Rh=MeanWeighted(), - Ri_SW_q=(source=:Ri_SW_f, reducer=RadiationEnergy()), -) -``` -""" -MeteoBindings(bindings) = x -> with_meteo_bindings(x, bindings) -MeteoBindings(; kwargs...) = MeteoBindings((; kwargs...)) - -""" - MeteoWindow(window) - -Pipe-style transform that sets the weather row-selection window for one model. - -This controls which meteo rows are sampled before `MeteoBindings` reducers are -applied. - -# Arguments -- `window`: a `PlantMeteo.AbstractSamplingWindow` instance/type. - Typical values are: - - `PlantMeteo.RollingWindow()` (default trailing window), - - `PlantMeteo.CalendarWindow(...)` (calendar-aligned day/week/month windows). - -# Example -```julia -ModelSpec(DailyModel()) |> -TimeStepModel(ClockSpec(24.0, 0.0)) |> -MeteoWindow(CalendarWindow(:day; anchor=:current_period, week_start=1, completeness=:strict)) -``` -""" -MeteoWindow(window) = x -> with_meteo_window(x, window) - -""" - OutputRouting(routing) - OutputRouting(; kwargs...) - -Pipe-style transform that sets output publication mode for a model. - -This is mainly used to disambiguate publishers in multi-rate runs when several -models write variables with the same name. - -# Arguments -- `routing::NamedTuple`: maps output variable symbols to routing mode. -- `kwargs...`: keyword shorthand equivalent to a `NamedTuple`. - -Allowed routing values: -- `:canonical` (default): output is considered canonical at that scale and can - be auto-selected as source/export publisher. -- `:stream_only`: output is kept only in temporal streams and excluded from - canonical publisher resolution. - -# Example -```julia -ModelSpec(AltSourceModel()) |> -OutputRouting(; C=:stream_only) -``` -""" -OutputRouting(routing) = x -> with_output_routing(x, routing) -OutputRouting(; kwargs...) = OutputRouting((; kwargs...)) - -""" - ScopeModel(scope) - -Pipe-style transform that sets stream scope selection for a model. - -Scope controls how temporal streams are partitioned/resolved across entities in -multi-rate simulations. - -# Arguments -- `scope`: one of: - - selector symbols/strings: `:global`, `:plant`, `:scene`, `:self`, - - a concrete `ScopeId`, - - a callable returning a scope selector/id at runtime. - -# Example -```julia -ModelSpec(LeafSourceModel()) |> -ScopeModel(:plant) -``` -""" -ScopeModel(scope) = x -> with_scope(x, scope) - -model_(m::ModelSpec) = m.model -mapped_variables_(m::ModelSpec) = isnothing(m.multiscale) ? Pair{Symbol,String}[] : m.multiscale -get_models(m::ModelSpec) = [model_(m)] -get_status(m::ModelSpec) = nothing -get_mapped_variables(m::ModelSpec) = mapped_variables_(m) -process(m::ModelSpec) = process(model_(m)) -timestep(m::ModelSpec) = m.timestep diff --git a/src/mtg/MultiScaleModel.jl b/src/mtg/MultiScaleModel.jl deleted file mode 100644 index a5535af6f..000000000 --- a/src/mtg/MultiScaleModel.jl +++ /dev/null @@ -1,219 +0,0 @@ -""" - MultiScaleModel(model, mapped_variables) - -A structure to make a model multi-scale. It defines a mapping between the variables of a -model and the nodes symbols from which the values are taken from. - -# Arguments - -- `model<:AbstractModel`: the model to make multi-scale -- `mapped_variables<:Vector{Pair{Symbol,Union{Symbol,AbstractString,Vector{<:Union{Symbol,AbstractString}}}}}`: - a vector of pairs of model variables and source scale declarations. - -The mapped_variables argument can be of the form: - -1. `[:variable_name => (:Plant => :variable_name)]`: We take one value from the Plant node -2. `[:variable_name => [:Leaf]]`: We take a vector of values from the Leaf nodes -3. `[:variable_name => [:Leaf, :Internode]]`: We take a vector of values from the Leaf and Internode nodes -4. `[:variable_name => (:Plant => :variable_name_in_plant_scale)]`: We take one value from another variable name in the Plant node -5. `[:variable_name => [:Leaf => :variable_name_1, :Internode => :variable_name_2]]`: We take a vector of values from the Leaf and Internode nodes with different names -6. `[PreviousTimeStep(:variable_name) => ...]`: We flag the variable to be initialized with the value from the previous time step, and we do not use it to build the dep graph -7. `[:variable_name => (Symbol() => :variable_name_from_another_model)]`: We take the value from another model at the same scale but rename it -8. `[PreviousTimeStep(:variable_name),]`: We just flag the variable as a PreviousTimeStep to not use it to build the dep graph - -Details about the different forms: - -1. The variable `variable_name` of the model will be taken from the `Plant` node, assuming only one node has the `Plant` symbol. -In this case the value available from the status will be a scalar, and so the user must guaranty that only one node of type `Plant` is available in the MTG. - -2. The variable `variable_name` of the model will be taken from the `Leaf` nodes. Notice it is given as a vector, indicating that the values will be taken -from all the nodes of type `Leaf`. The model should be able to handle a vector of values. Note that even if there is only one node of type `Leaf`, the value -will be taken as a vector of one element. - -3. The variable `variable_name` of the model will be taken from the `Leaf` and `Internode` nodes. The values will be taken from all the nodes of type `Leaf` -and `Internode`. - -4. The variable `variable_name` of the model will be taken from the variable called `variable_name_in_plant_scale` in the `Plant` node. This is useful -when the variable name in the model is different from the variable name in the scale it is taken from. - -5. The variable `variable_name` of the model will be taken from the variable called `variable_name_1` in the `Leaf` node and `variable_name_2` in the `Internode` node. - -6. The variable `variable_name` of the model uses the value computed on the previous time-step. This implies that the variable is not used to build the dependency graph -because the dependency graph only applies on the current time-step. This is used to avoid circular dependencies when a variable depends on itself. The value can be initialized -in the Status if needed. - -7. The variable `variable_name` of the model will be taken from another model at the same scale, but with another variable name. - -8. The variable `variable_name` of the model is just flagged as a PreviousTimeStep variable, so it is not used to build the dependency graph. - - - -Note that the mapping does not make any copy of the values, it only references them. This means that if the values are updated in the status -of one node, they will be updated in the other nodes. - -# Examples - -```jldoctest mylabel -julia> using PlantSimEngine; -``` - -Including example processes and models: - -```jldoctest mylabel -julia> using PlantSimEngine.Examples; -``` - -Let's take a model: - -```jldoctest mylabel -julia> model = ToyCAllocationModel() -ToyCAllocationModel() -``` - -We can make it multi-scale by defining a mapping between the variables of the model and the nodes symbols from which the values are taken from: - -For example, if the `carbon_allocation` comes from the `Leaf` and `Internode` nodes, we can define the mapping as follows: - -```jldoctest mylabel -julia> mapped_variables = [:carbon_allocation => [:Leaf, :Internode]] -1-element Vector{Pair{Symbol, Vector{Symbol}}}: - :carbon_allocation => [:Leaf, :Internode] -``` - -The mapped_variables argument is a vector of pairs of symbols and symbol scales. In this case, we have only one pair to define the mapping -between the `carbon_allocation` variable and the `Leaf` and `Internode` nodes. - -We can now make the model multi-scale by passing the model and the mapped variables to the `MultiScaleModel` constructor : - -```jldoctest mylabel -julia> multiscale_model = PlantSimEngine.MultiScaleModel(model, mapped_variables); -``` - -We can access the mapped variables and the model: - -```jldoctest mylabel -julia> PlantSimEngine.mapped_variables_(multiscale_model) == [:carbon_allocation => [:Leaf => :carbon_allocation, :Internode => :carbon_allocation]] -true -``` - -```jldoctest mylabel -julia> PlantSimEngine.model_(multiscale_model) -ToyCAllocationModel() -``` -""" -struct MultiScaleModel{ - T<:AbstractModel, - V<:AbstractVector{ - Pair{ - A, - Union{Pair{Symbol,Symbol},Vector{Pair{Symbol,Symbol}}} - } - } where {A<:Union{Symbol,PreviousTimeStep}} -} - model::T - mapped_variables::V - - function MultiScaleModel{T}(model::T, mapped_variables) where {T<:AbstractModel} - # Check that the variables in the mapping are variables of the model: - model_variables = keys(variables(model)) - for i in mapped_variables - # If the var is a PreviousTimeStep, we take the variable name, else take the first element of the pair: - var = isa(i, PreviousTimeStep) ? i.variable : first(i) - - # If it was a pair, the first element can still be a PreviousTimeStep, so we take the variable name: - var = isa(var, PreviousTimeStep) ? var.variable : var - - if !(var in model_variables) - error("Mapping for model $model defines variable $var, but it is not a variable of the model.") - end - end - - # If the name of the variable mapped from the other scale is not given, we add it as the same of the variable name in the model. Cases: - # 1. `[:variable_name => (:Plant => :variable_name)]` # We take one value from the Plant node - # 2. `[:variable_name => [:Leaf]]` # We take a vector of values from the Leaf nodes - # 3. `[:variable_name => [:Leaf, :Internode]]` # We take a vector of values from the Leaf and Internode nodes - # 4. `[:variable_name => (:Plant => :variable_name_in_plant_scale)]` # We take one value from another variable name in the Plant node - # 5. `[:variable_name => [:Leaf => :variable_name_1, :Internode => :variable_name_2]]` # We take a vector of values from the Leaf and Internode nodes with different names - # 6. `[PreviousTimeStep(:variable_name) => ...]` # We flag the variable to be initialized with the value from the previous time step, and we do not use it to build the dep graph - # 7. `[:variable_name => (Symbol("") => :variable_name_from_another_model)]` # We take the value from another model at the same scale but rename it - # 8. `[PreviousTimeStep(:variable_name),]` # We just flag the variable as a PreviousTimeStep to not use it to build the dep graph - - process_ = process(model) - unfolded_mapping = Pair{Union{Symbol,PreviousTimeStep},Union{Pair{Symbol,Symbol},Vector{Pair{Symbol,Symbol}}}}[] - for i in mapped_variables - push!(unfolded_mapping, _get_var(isa(i, PreviousTimeStep) ? i : Pair(i.first, i.second), process_)) - # Note: We are using Pair(i.first, i.second) to make sure the Pair is specialized enough, because sometimes the vector in the mapping made the Pair not specialized enough e.g. [:v1 => :S => :v2,:v3 => :S] makes the pairs `Pair{Symbol, Any}`. - end - - new{T,typeof(unfolded_mapping)}(model, unfolded_mapping) - end -end - -# Method used when the vector in the mapping made the Pair not specialized enough e.g. [:v1 => :S => :v2,:v3 => :S] makes the pairs `Pair{Symbol, Any}`. -function _get_var(i::Pair{Symbol,Any}, proc::Symbol=:unknown) - return _get_var(first(i) => last(i), proc) -end - -@noinline function _warn_multiscale_model_string_scale() - Base.depwarn( - "String scale names in `MultiScaleModel(mapped_variables=...)` are deprecated and will be removed in a future release. Use Symbol scales, e.g. `:Leaf` instead of `\"Leaf\"`.", - :MultiScaleModel - ) -end - -_normalize_mapped_scale(scale::Symbol) = scale -function _normalize_mapped_scale(scale::AbstractString) - _warn_multiscale_model_string_scale() - return Symbol(scale) -end - -# Case 1: [:variable_name => :Plant] (deprecated, coerced to symbol scale) -function _get_var(i::Pair{Symbol,S}, proc::Symbol=:unknown) where {S<:AbstractString} - scale = _normalize_mapped_scale(last(i)) - return first(i) => scale => first(i) -end - -function _get_var(i::Pair{Symbol,Pair{S,Symbol}}, proc::Symbol=:unknown) where {S<:Union{AbstractString,Symbol}} - return first(i) => (_normalize_mapped_scale(first(last(i))) => last(last(i))) -end - -function _get_var(i::Pair{Symbol,T}, proc::Symbol=:unknown) where {T<:AbstractVector{<:Union{AbstractString,Symbol}}} - return first(i) => [_normalize_mapped_scale(scale) => first(i) for scale in last(i)] -end - -# Case 5: [:variable_name => [:Leaf => :variable_name_1, :Internode => :variable_name_2]] -function _get_var(i::Pair{Symbol,T}, proc::Symbol=:unknown) where {T<:AbstractVector{<:Pair{<:Union{AbstractString,Symbol},Symbol}}} - return first(i) => [_normalize_mapped_scale(first(mapping)) => last(mapping) for mapping in last(i)] -end - -# Case 6: [PreviousTimeStep(:variable_name) => ...] -function _get_var(i::Pair{PreviousTimeStep,T}, proc::Symbol=:unknown) where {T} - vars = _get_var(i.first.variable => i.second, proc) # Leverage the other methods here - return PreviousTimeStep(first(i).variable, proc) => last(vars) # And put back the PreviousTimeStep as the first element -end - -# Case 7: [:variable_name => :Plant] -function _get_var(i::Pair{Symbol,Symbol}, proc::Symbol=:unknown) - return first(i) => last(i) => first(i) -end - -# Case 8: [PreviousTimeStep(:variable_name),] -function _get_var(i::PreviousTimeStep, proc::Symbol=:unknown) - return PreviousTimeStep(i.variable, proc) => Symbol("") => i.variable -end - - - -function MultiScaleModel(model::T, mapped_variables) where {T<:AbstractModel} - MultiScaleModel{T}(model, mapped_variables) -end -MultiScaleModel(; model, mapped_variables) = MultiScaleModel(model, mapped_variables) - -mapped_variables_(m::MultiScaleModel) = m.mapped_variables -model_(m::MultiScaleModel) = m.model -inputs_(m::MultiScaleModel) = inputs_(m.model) -outputs_(m::MultiScaleModel) = outputs_(m.model) -get_models(m::MultiScaleModel) = [model_(m)] # Get the models of a MultiScaleModel: -# Note: it is returning a vector of models, because in this case the user provided a single MultiScaleModel instead of a vector of. -get_status(m::MultiScaleModel) = nothing -get_mapped_variables(m::MultiScaleModel{T,S}) where {T,S} = mapped_variables_(m) diff --git a/src/mtg/add_organ.jl b/src/mtg/add_organ.jl deleted file mode 100644 index 784127f89..000000000 --- a/src/mtg/add_organ.jl +++ /dev/null @@ -1,37 +0,0 @@ -""" - add_organ!(node::MultiScaleTreeGraph.Node, sim_object, link, symbol, scale; index=0, id=MultiScaleTreeGraph.new_id(MultiScaleTreeGraph.get_root(node)), attributes=Dict{Symbol,Any}(), check=true) - -Add an organ to the graph, automatically taking care of initialising the status of the organ (multiscale-)variables. - -This function should be called from a model that implements organ emergence, for example in function of thermal time. - -# Arguments - -* `node`: the node to which the organ is added (the parent organ of the new organ) -* `sim_object`: the simulation object, e.g. the `GraphSimulation` object from the `extra` argument of a model. -* `link`: the link type between the new node and the organ: - * `"<"`: the new node is following the parent organ - * `"+"`: the new node is branching the parent organ - * `"/"`: the new node is decomposing the parent organ, *i.e.* we change scale -* `symbol`: the symbol of the organ, *e.g.* `:Leaf` -* `scale`: the scale of the organ, *e.g.* `2`. -* `index`: the index of the organ, *e.g.* `1`. The index may be used to easily identify branching order, or growth unit index on the axis. It is different from the node `id` that is unique. -* `id`: the unique id of the new node. If not provided, a new id is generated. -* `attributes`: the attributes of the new node. If not provided, an empty dictionary is used. -* `check`: a boolean indicating if variables initialisation should be checked. Passed to `init_node_status!`. - -# Returns - -* `status`: the status of the new node - -# Examples - -See the `ToyInternodeEmergence` example model from the `Examples` module (also found in the `examples` folder), -or the `test-mtg-dynamic.jl` test file for an example usage. -""" -function add_organ!(node::MultiScaleTreeGraph.Node, sim_object, link, symbol, scale; index=0, id=MultiScaleTreeGraph.new_id(MultiScaleTreeGraph.get_root(node)), attributes=Dict{Symbol,Any}(), check=true) - new_node = MultiScaleTreeGraph.Node(id, node, MultiScaleTreeGraph.NodeMTG(link, symbol, index, scale), attributes) - st = init_node_status!(new_node, sim_object.statuses, sim_object.status_templates, sim_object.reverse_multiscale_mapping, sim_object.var_need_init, check=check) - - return st -end \ No newline at end of file diff --git a/src/mtg/initialisation.jl b/src/mtg/initialisation.jl deleted file mode 100644 index 98aa612c6..000000000 --- a/src/mtg/initialisation.jl +++ /dev/null @@ -1,389 +0,0 @@ -""" - init_statuses(mtg, mapping, dependency_graph=dep(mapping); type_promotion=nothing, verbose=true, check=true) - -Get the status of each node in the MTG by node type, pre-initialised considering multi-scale variables. - -# Arguments - -- `mtg`: the plant graph -- `mapping`: a dictionary of model mapping -- `dependency_graph::DependencyGraph`: the first-order dependency graph where each model in the mapping is assigned a node. -However, models that are identified as hard-dependencies are not given individual nodes. Instead, they are nested as child -nodes under other models. -- `type_promotion`: the type promotion to use for the variables -- `verbose`: print information when compiling the mapping -- `check`: whether to check the mapping for errors. Passed to `init_node_status!`. - -# Return - -A NamedTuple of status by node type, a dictionary of status templates by node type, a dictionary of variables mapped to other scales, -a dictionary of variables that need to be initialised or computed by other models, and a vector of nodes that have a model defined for their symbol: - -`(;statuses, status_templates, reverse_multiscale_mapping, vars_need_init, nodes_with_models)` -""" -function init_statuses(mtg, mapping, dependency_graph; type_promotion=nothing, verbose=false, check=true) - # We compute the variables mapping for each scale: - mapped_vars = mapped_variables(mapping, dependency_graph, verbose=verbose) - - # Update the types of the variables as desired by the user: - convert_vars!(mapped_vars, type_promotion) - - # Compute the reverse multiscale dependencies, *i.e.* for each scale, which variable is mapped to the other scale - reverse_multiscale_mapping = reverse_mapping(mapped_vars, all=false) - # Note: this is used when we add a node value for such variable in the RefVector of the other scale. - # Note 2: we use the `all=false` option to only get the variables that are mapped to another scale as a vector. - # Note 3: we do it before `convert_reference_values!` because we need the variables to be MappedVar{MultiNodeMapping} to get the reverse mapping. - - # Convert the MappedVar{SelfNodeMapping} or MappedVar{SingleNodeMapping} to RefValues, and MappedVar{MultiNodeMapping} to RefVectors: - convert_reference_values!(mapped_vars) - - # Get the variables that are not initialised or computed by other models in the output: - vars_need_init = Dict(org => filter(x -> isa(last(x), UninitializedVar), vars) |> keys for (org, vars) in mapped_vars) |> - filter(x -> length(last(x)) > 0) - - # Note: these variables may be present in the MTG attributes, we check that below when traversing the MTG. - - # We traverse the MTG to initialise the statuses linked to the nodes: - statuses = Dict(i => Status[] for i in collect(keys(mapped_vars))) - MultiScaleTreeGraph.traverse!(mtg) do node # e.g.: node = MultiScaleTreeGraph.get_node(mtg, 5) - init_node_status!(node, statuses, mapped_vars, reverse_multiscale_mapping, vars_need_init, type_promotion, check=check) - end - - return (; statuses, mapped_vars, reverse_multiscale_mapping, vars_need_init) -end - - -""" - init_node_status!( - node, - statuses, - mapped_vars, - reverse_multiscale_mapping, - vars_need_init=Dict{Symbol,Any}(), - type_promotion=nothing; - check=true, - attribute_name=:plantsimengine_status) - ) - -Initialise the status of a plant graph node, taking into account the multiscale mapping, and add it to the statuses dictionary. - -# Arguments - -- `node`: the node to initialise -- `statuses`: the dictionary of statuses by node type -- `mapped_vars`: the template of status for each node type -- `reverse_multiscale_mapping`: the variables that are mapped to other scales -- `var_need_init`: the variables that are not initialised or computed by other models -- `nodes_with_models`: the nodes that have a model defined for their symbol -- `type_promotion`: the type promotion to use for the variables -- `check`: whether to check the mapping for errors (see details) -- `attribute_name`: the name of the attribute to store the status in the node, by default: `:plantsimengine_status` - -# Details - -Most arguments can be computed from the graph and the mapping: -- `statuses` is given by the first initialisation: `statuses = Dict(i => Status[] for i in nodes_with_models)` -- `mapped_vars` is computed using `mapped_variables()`, see code in `init_statuses` -- `vars_need_init` is computed using `vars_need_init = Dict(org => filter(x -> isa(last(x), UninitializedVar), vars) |> keys for (org, vars) in mapped_vars) |> -filter(x -> length(last(x)) > 0)` - -The `check` argument is a boolean indicating if variables initialisation should be checked. In the case that some variables need initialisation (partially initialized mapping), we check if the value can be found -in the node attributes (using the variable name). If `true`, the function returns an error if the attribute is missing, otherwise it uses the default value from the model. - -""" -function init_node_status!(node, statuses, mapped_vars, reverse_multiscale_mapping, vars_need_init=Dict{Symbol,Any}(), type_promotion=nothing; check=true, attribute_name=:plantsimengine_status) - node_scale = symbol(node) - - # Check if the node has a model defined for its symbol, if not, no need to compute - haskey(mapped_vars, node_scale) || return - - # We make a copy of the template status for this node: - st_template = copy(mapped_vars[node_scale]) - - # We add a reference to the node into the status, so that we can access it from the models if needed. - push!(st_template, :node => Ref(node)) - - # If some variables still need to be instantiated in the status, look into the MTG node if we can find them, - # and if so, use their value in the status: - if haskey(vars_need_init, node_scale) && length(vars_need_init[node_scale]) > 0 - for var in vars_need_init[node_scale] # e.g. var = :carbon_biomass - if !haskey(node, var) - if !check - # If we don't check, we use the default value from the model (and if it's an UninitializedVar we take its default value): - if isa(st_template[var], UninitializedVar) - st_template[var] = st_template[var].value - end - continue - end - error("Variable `$(var)` is not computed by any model, not initialised by the user in the status, and not found in the MTG at scale $(symbol(node)) (checked for MTG node $(node_id(node))).") - end - # Applying the type promotion to the node attribute if needed: - if isnothing(type_promotion) - node_var = node[var] - else - node_var = - try - promoted_var_type = [] - for (subtype, newtype) in type_promotion - if isa(node[var], subtype) - converted_var = convert(newtype, node[var]) - @warn "Promoting `$(var)` value taken from MTG node $(node_id(node)) ($(symbol(node))) from $subtype to $newtype: $converted_var ($(typeof(converted_var)))" maxlog = 5 - push!(promoted_var_type, converted_var) - end - end - length(promoted_var_type) > 0 ? promoted_var_type[1] : node[var] - catch e - error("Failed to convert variable `$(var)` in MTG node $(node_id(node)) ($(symbol(node))) from type `$(typeof(node[var]))` to type `$(eltype(st_template[var]))`: $(e)") - end - end - @assert typeof(node_var) == eltype(st_template[var]) string( - "Initializing variable `$(var)` using MTG node $(node_id(node)) ($(symbol(node))): expected type $(eltype(st_template[var])), found $(typeof(node_var)). ", - "Please check the type of the variable in the MTG, and make it a $(eltype(st_template[var])) by updating the model, or by using `type_promotion`." - ) - st_template[var] = node_var - # NB: the variable is not a reference to the value in the MTG, but a copy of it. - # This is because we can't reference a value in a Dict. If we need a ref, the user can use a RefValue in the MTG directly, - # and it will be automatically passed as is. - end - end - - # Make the node status from the template: - st = status_from_template(st_template) - - push!(statuses[node_scale], st) - - # Instantiate the RefVectors on the fly for other scales that map into this scale, *i.e.* - # add a reference to the value of any variable that is used by another scale into its RefVector: - if haskey(reverse_multiscale_mapping, node_scale) - for (organ, vars) in reverse_multiscale_mapping[node_scale] # e.g.: organ = :Leaf; vars = reverse_multiscale_mapping[symbol(node)][organ] - for (var_source, var_target_) in vars # e.g.: var_source = :soil_water_content; var_target = vars[var_source] - var_target = var_target_ isa PreviousTimeStep ? var_target_.variable : var_target_ - push!(mapped_vars[organ][var_target], refvalue(st, var_source)) - end - end - end - - - # Finally, we add the status to the node: - node[attribute_name] = st - - return st -end - -""" - status_from_template(d::Dict{Symbol,Any}) - -Create a status from a template dictionary of variables and values. If the values -are already RefValues or RefVectors, they are used as is, else they are converted to Refs. - -# Arguments - -- `d::Dict{Symbol,Any}`: A dictionary of variables and values. - -# Returns - -- A [`Status`](@ref). - -# Examples - -```jldoctest mylabel -julia> using PlantSimEngine -``` - -```jldoctest mylabel -julia> a, b = PlantSimEngine.status_from_template(Dict(:a => 1.0, :b => 2.0)); -``` - -```jldoctest mylabel -julia> a -1.0 -``` - -```jldoctest mylabel -julia> b -2.0 -``` -""" -function status_from_template(d::Dict{Symbol,T} where {T}) - # Sort vars to put the values that are of type PerStatusRef at the end (we need the pass on the other ones first): - sorted_vars = Dict{Symbol,Any}(sort([pairs(d)...], by=v -> last(v) isa RefVariable ? 1 : 0)) - # Note: PerStatusRef are used to reference variables in the same status for renaming. - - # We create the status with the right references for variables, and for PerStatusRef (we reference the reference variable): - for (k, v) in sorted_vars - if isa(v, RefVariable) - sorted_vars[k] = sorted_vars[v.reference_variable] - else - sorted_vars[k] = ref_var(v) - end - end - - return Status(NamedTuple(sorted_vars)) -end - -""" - ref_var(v) - -Create a reference to a variable. If the variable is already a `Base.RefValue`, -it is returned as is, else it is returned as a Ref to the copy of the value, -or a Ref to the `RefVector` (in case `v` is a `RefVector`). - -# Examples - -```jldoctest mylabel -julia> using PlantSimEngine; -``` - -```jldoctest mylabel -julia> PlantSimEngine.ref_var(1.0) -Base.RefValue{Float64}(1.0) -``` - -```jldoctest mylabel -julia> PlantSimEngine.ref_var([1.0]) -Base.RefValue{Vector{Float64}}([1.0]) -``` - -```jldoctest mylabel -julia> PlantSimEngine.ref_var(Base.RefValue(1.0)) -Base.RefValue{Float64}(1.0) -``` - -```jldoctest mylabel -julia> PlantSimEngine.ref_var(Base.RefValue([1.0])) -Base.RefValue{Vector{Float64}}([1.0]) -``` - -```jldoctest mylabel -julia> PlantSimEngine.ref_var(PlantSimEngine.RefVector([Ref(1.0), Ref(2.0), Ref(3.0)])) -Base.RefValue{PlantSimEngine.RefVector{Float64}}(RefVector{Float64}[1.0, 2.0, 3.0]) -``` -""" -ref_var(v) = Base.Ref(copy(v)) -ref_var(v::T) where {T<:AbstractString} = Base.Ref(v) # No copy method for strings, so directly making a Ref out of it -ref_var(v::T) where {T<:Symbol} = Base.Ref(v) # No copy method for strings, so directly making a Ref out of it -ref_var(v::T) where {T<:Base.RefValue} = v -ref_var(v::T) where {T<:RefVector} = Base.Ref(v) -ref_var(v::T) where {T<:RefVariable} = v -ref_var(v::UninitializedVar) = Base.Ref(copy(v.value)) - -""" - init_simulation(mtg, mapping; nsteps=1, outputs=nothing, type_promotion=nothing, check=true, verbose=true) - -Initialise the simulation. Returns: - -- the mtg -- a status for each node by organ type, considering multi-scale variables -- the dependency graph of the models -- the models parsed as a Dict of organ type => NamedTuple of process => model mapping -- the pre-allocated outputs - -# Arguments - -- `mtg`: the MTG -- `mapping::ModelMapping` (or dictionary-like mapping): associates scales to models/status. -- `nsteps`: the number of steps of the simulation -- `outputs`: the dynamic outputs needed for the simulation -- `type_promotion`: the type promotion to use for the variables -- `check`: whether to check the mapping for errors. Passed to `init_node_status!`. -- `verbose`: print information about errors in the mapping - -# Details - -The function first computes a template of status for each organ type that has a model in the mapping. -This template is used to initialise the status of each node of the MTG, taking into account the user-defined -initialisation, and the (multiscale) mapping. The mapping is used to make references to the variables -that are defined at another scale, so that the values are automatically updated when the variable is changed at -the other scale. Two types of multiscale variables are available: `RefVector` and `MappedVar`. The first one is -used when the variable is mapped to a vector of nodes, and the second one when it is mapped to a single node. This -is given by the user through the mapping, using a symbol for a single node (*e.g.* `=> :Leaf`), and a vector of symbols for a vector of -nodes (*e.g.* `=> [:Leaf]` for one type of node or `=> [:Leaf, :Internode]` for several). - -The function also computes the dependency graph of the models, i.e. the order in which the models should be -called, considering the dependencies between them. The dependency graph is used to call the models in the right order -when the simulation is run. - -Note that if a variable is not computed by models or initialised from the mapping, it is searched in the MTG attributes. -The value is not a reference to the one in the attribute of the MTG, but a copy of it. This is because we can't reference -a value in a Dict. If you need a reference, you can use a `Ref` for your variable in the MTG directly, and it will be -automatically passed as is. -""" -function init_simulation(mtg, mapping; nsteps=1, outputs=nothing, type_promotion=nothing, check=true, verbose=false) - - # Ensure the user called the model generation function to handle vectors passed into a status - # before we keep going - (organ_with_vector, no_vectors_found) = (check_statuses_contain_no_remaining_vectors(mapping)) - if !no_vectors_found - @assert false "Error : Mapping status at $organ_with_vector level contains a vector. If this was intentional, call the function generate_models_from_status_vectors on your mapping before calling run!. And bear in mind this is not meant for production. If this wasn't intentional, then it's likely an issue on the mapping definition, or an unusual model." - end - - models = Dict(first(m) => parse_models(get_models(last(m))) for m in mapping) - model_specs = if mapping isa ModelMapping && !isempty(mapping.info.model_specs) - deepcopy(mapping.info.model_specs) - else - Dict(first(m) => parse_model_specs(last(m)) for m in mapping) - end - - soft_dep_graphs_roots, hard_dep_dict = hard_dependencies(mapping; verbose=false) - - scale_reachability = _scale_reachability_from_mtg(mtg) - _infer_timestep_hints!(model_specs) - ignored_same_rate_hard_children = _same_rate_hard_dependency_children(model_specs, soft_dep_graphs_roots) - active_processes_by_scale = _active_processes_for_inference(model_specs, ignored_same_rate_hard_children) - infer_model_specs_configuration!( - model_specs; - scale_reachability=scale_reachability, - active_processes_by_scale=active_processes_by_scale - ) - validate_model_specs_configuration(model_specs) - - # Get the status of each node by node type, pre-initialised considering multi-scale variables: - statuses, status_templates, reverse_multiscale_mapping, vars_need_init = - init_statuses(mtg, mapping, soft_dep_graphs_roots; type_promotion=type_promotion, verbose=verbose, check=check) - - # First step, get the hard-dependency graph and create SoftDependencyNodes for each hard-dependency root. In other word, we want - # only the nodes that are not hard-dependency of other nodes. These nodes are taken as roots for the soft-dependency graph because they - # are independant. - # Second step, compute the soft-dependency graph between SoftDependencyNodes computed in the first step. To do so, we search the - # inputs of each process into the outputs of the other processes, at the same scale, but also between scales. Then we keep only the - # nodes that have no soft-dependencies, and we set them as root nodes of the soft-dependency graph. The other nodes are set as children - # of the nodes that they depend on. - dep_graph = soft_dependencies_multiscale(soft_dep_graphs_roots, reverse_multiscale_mapping, hard_dep_dict) - # During the building of the soft-dependency graph, we identified the inputs and outputs of each dependency node, - # and also defined **inputs** as MappedVar if they are multiscale, i.e. if they take their values from another scale. - # What we are missing is that we need to also define **outputs** as multiscale if they are needed by another scale. - - # Checking that the graph is acyclic: - iscyclic, cycle_vec = is_graph_cyclic(dep_graph; warn=false) - # Note: we could do that in `soft_dependencies_multiscale` but we prefer to keep the function as simple as possible, and - # usable on its own. - - iscyclic && error("Cyclic dependency detected in the graph. Cycle: \n $(print_cycle(cycle_vec)) \n You can break the cycle using the `PreviousTimeStep` variable in the mapping.") - # Third step, we identify which - - # Print an info if models are declared for nodes that don't exist in the MTG: - if check && any(x -> length(last(x)) == 0, statuses) - model_no_node = join(findall(x -> length(x) == 0, statuses), ", ") - @info "Models given for $model_no_node, but no node with this symbol was found in the MTG." maxlog = 1 - end - - outputs = pre_allocate_outputs(statuses, status_templates, reverse_multiscale_mapping, vars_need_init, outputs, nsteps, type_promotion=type_promotion, check=check) - - outputs_index = Dict{Symbol,Int}(s => 1 for s in keys(outputs)) - temporal_state = TemporalState() - mapping_is_multirate = mapping isa ModelMapping ? is_multirate(mapping) : false - return (; - mtg, - statuses, - status_templates, - reverse_multiscale_mapping, - vars_need_init, - dependency_graph=dep_graph, - models, - model_specs, - outputs, - outputs_index, - temporal_state, - is_multirate=mapping_is_multirate - ) -end diff --git a/src/mtg/mapping/compute_mapping.jl b/src/mtg/mapping/compute_mapping.jl deleted file mode 100644 index a6ccabccf..000000000 --- a/src/mtg/mapping/compute_mapping.jl +++ /dev/null @@ -1,357 +0,0 @@ -""" - mapped_variables(mapping, dependency_graph=first(hard_dependencies(mapping; verbose=false)); verbose=false) - -Get the variables for each organ type from a dependency graph, with `MappedVar`s for the multiscale mapping. - -# Arguments - -- `mapping::ModelMapping` (or dictionary-like mapping): the mapping between models and scales. -- `dependency_graph::DependencyGraph`: the first-order dependency graph where each model in the mapping is assigned a node. -However, models that are identified as hard-dependencies are not given individual nodes. Instead, they are nested as child -nodes under other models. -- `verbose::Bool`: whether to print the stacktrace of the search for the default value in the mapping. -""" -function mapped_variables(mapping, dependency_graph=first(hard_dependencies(mapping; verbose=false)); verbose=false) - # Initialise a dict that defines the multiscale variables for each organ type: - mapped_vars = mapped_variables_no_outputs_from_other_scale(mapping, dependency_graph) - - # Add the variables that are outputs from another scale and not computed at a scale otherwise, and add them to the organs_mapping: - add_mapped_variables_with_outputs_as_inputs!(mapped_vars) - # E.g.: carbon allocation is computed at the plant scale, but then allocated to each organ (Leaf and Internode) from the same model. - # Which means that the Leaf and Internodes scales should have the carbon allocation as an input variable. - - # Find variables that are inputs to other scales as a `SingleNodeMapping` and declare them as MappedVar from themselves in the source scale. - # This helps us declare it as a reference when we create the template status objects. - transform_single_node_mapped_variables_as_self_node_output!(mapped_vars) - - # We now merge inputs and outputs into a single dictionary: - mapped_vars_per_organ = merge(merge, mapped_vars[:inputs], mapped_vars[:outputs]) - #* Important note: the merge order is important, because if an input variable is uninitialized but computed by a model at the same scale, - #* we don't need any initialisation (it is computed), so we take the default value from the output (that is the default value from the model). - #* It is particularly important for variables that are PreviousTimeStep and mapping to another scale, because we need its initialisation from that other scale - #* that is an output. - mapped_vars = default_variables_from_mapping(mapped_vars_per_organ, verbose) - - return mapped_vars -end - -""" - mapped_variables_no_outputs_from_other_scale(mapping, dependency_graph=first(hard_dependencies(mapping; verbose=false))) - -Get the variables for each organ type from a dependency graph, without the variables that are outputs from another scale. - -# Arguments - -- `mapping::ModelMapping` (or dictionary-like mapping): the mapping between models and scales. -- `dependency_graph::DependencyGraph`: the first-order dependency graph where each model in the mapping is assigned a node. -However, models that are identified as hard-dependencies are not given individual nodes. Instead, they are nested as child -nodes under other models. - -# Details - -This function returns a dictionary with the (multiscale-) inputs and outputs variables for each organ type. - -Note that this function does not include the variables that are outputs from another scale and not computed by this scale, -see `mapped_variables_with_outputs_as_inputs` for that. - """ -function mapped_variables_no_outputs_from_other_scale(mapping, dependency_graph=first(hard_dependencies(mapping; verbose=false))) - nodes_insouts = Dict(organ => (inputs=ins, outputs=outs) for (organ, (soft_dep_graph, ins, outs)) in dependency_graph.roots) - ins = Dict{Symbol,NamedTuple}(organ => flatten_vars(vcat(values(ins)...)) for (organ, (ins, outs)) in nodes_insouts) - outs = Dict{Symbol,NamedTuple}(organ => flatten_vars(vcat(values(outs)...)) for (organ, (ins, outs)) in nodes_insouts) - - return Dict(:inputs => ins, :outputs => outs) -end - -""" - variables_outputs_from_other_scale(mapped_vars) - -For each organ in the `mapped_vars`, find the variables that are outputs from another scale and not computed at this scale otherwise. -This function is used with mapped_variables -""" -function variables_outputs_from_other_scale(mapped_vars) - vars_outputs_from_scales = Dict{Symbol,Vector{Pair{Symbol,Any}}}() - # Scale at which we have to add a variable => [(source_process, source_scale, variable), ...] - for (organ, outs) in mapped_vars[:outputs] # organ = :Leaf ; outs = mapped_vars[:outputs][organ] - for (var, val) in pairs(outs) # var = :carbon_biomass ; val = outs[1] - if isa(val, MappedVar) - orgs = mapped_organ(val) - isnothing(orgs) && continue - orgs_iterable = isa(orgs, Symbol) ? Symbol[orgs] : Symbol[orgs...] - - filter!(o -> o != Symbol(""), orgs_iterable) - length(orgs_iterable) == 0 && continue # This can happen when we use a PreviousTimeStep alone - - for o in orgs_iterable - # The MappedVar can only have one value for the default, because it comes from the computing scale (the source scale): - var_default_value = mapped_default(val) - - if mapped_organ_type(val) == MultiNodeMapping - # The variable is written to several organs, the default value must be a vector: - if isa(var_default_value, AbstractVector) - # Mapping into a vector of organs, the default value must be a vector: - @assert length(var_default_value) == 1 "The variable `$(mapped_variable(val))` is an output variable computed by scale `$organ` and written to organs at scale `$(join(mapped_organ(val), ", "))`, " * - "but the default value coming from `$organ` is not of length 1: $(var_default_value). " * - "Make sure the model that computes this variable at scale `$organ` has a vector of values of length 1 as " * - "default outputs for variable `$(mapped_variable(val))`." - var_default_value = var_default_value[1] - else - error( - "The variable `$(mapped_variable(val))` is an output variable computed by scale `$organ` and written to organs at scale `$(join(mapped_organ(val), ", "))`, " * - "but the default value coming from `$organ` is of length 1: $(var_default_value) instead of a vector. " * - "Make sure the model that computes this variable at scale `$organ` has a vector of values of length 1 as " * - "default outputs for variable `$(mapped_variable(val))`." - ) - end - else - # The variables is mapped to a single organ, the default value must be a scalar: - @assert !isa(var_default_value, AbstractVector) "The variable `$(mapped_variable(val))` is an output variable computed by scale `$organ` and written to organ at scale `$o`, " * - "but the default value coming from `$organ` is a vector: $(var_default_value). " * - "Make sure the model that computes this variable at scale `$organ` has a scalar value as " * - "default outputs for variable `$(mapped_variable(val))` (*e.g.* $(var_default_value[1])), or update your mapping to map the organ as a vector: " * - """`$(mapped_variable(val)) => ["$o"]`.""" - end - # We make a MappedVar object to declare the variable as an input of this scale: - # mapped_var = MappedVar( - # SelfNodeMapping(), # The source organ is itself, we just do that so the variable exist in its status - # source_variable(val, o), - # source_variable(val, o), - # var_default_value, - # ) - - if !haskey(vars_outputs_from_scales, o) - vars_outputs_from_scales[o] = [source_variable(val, o) => var_default_value] - else - push!(vars_outputs_from_scales[o], source_variable(val, o) => var_default_value) - end - end - end - end - end - return vars_outputs_from_scales -end - - -""" - add_mapped_variables_with_outputs_as_inputs!(mapped_vars) - -Add the variables that are computed at a scale and written to another scale into the mapping. -""" -function add_mapped_variables_with_outputs_as_inputs!(mapped_vars) - # Get the variables computed by a scale and written to another scale (we have to add them as inputs to the "another" scale): - outputs_written_by_other_scales = variables_outputs_from_other_scale(mapped_vars) - - for (organ, vars) in outputs_written_by_other_scales # organ = "" ; vars = outputs_written_by_other_scales[organ] - if haskey(mapped_vars[:inputs], organ) - mapped_vars[:inputs][organ] = merge(mapped_vars[:inputs][organ], NamedTuple(first(v) => last(v) for v in vars)) - else - error("The scale $organ is mapped as an output scale from anothe scale, but is not declared in the mapping.") - end - end - - return mapped_vars -end - - -""" - transform_single_node_mapped_variables_as_self_node_output!(mapped_vars) - -Find variables that are inputs to other scales as a `SingleNodeMapping` and declare them as MappedVar from themselves in the source scale. -This helps us declare it as a reference when we create the template status objects. - -These node are found in the mapping as `[:variable_name => :Plant]` (notice that `:Plant` is a scalar value). -""" -function transform_single_node_mapped_variables_as_self_node_output!(mapped_vars) - for (organ, vars) in mapped_vars[:inputs] # e.g. organ = :Leaf; vars = mapped_vars[:inputs][organ] - for (var, mapped_var) in pairs(vars) # e.g. var = :carbon_biomass; mapped_var = vars[var] - if isa(mapped_var, MappedVar{SingleNodeMapping}) - source_organ = mapped_organ(mapped_var) - source_organ == Symbol("") && continue # We skip the variables that are mapped to themselves (e.g. [PreviousTimeStep(:variable_name)], or just renaming a variable) - @assert source_organ != organ "Variable `$var` is mapped to its own scale in organ $organ. This is not allowed." - - @assert haskey(mapped_vars[:outputs], source_organ) "Scale $source_organ not found in the mapping, but mapped to the $organ scale." - @assert haskey(mapped_vars[:outputs][source_organ], source_variable(mapped_var)) "The variable `$(source_variable(mapped_var))` is mapped from scale `$source_organ` to " * - "scale `$organ`, but is not computed by any model at `$source_organ` scale." - - # If the source variable was already defined as a `MappedVar{SelfNodeMapping}` by another scale, we skip it: - isa(mapped_vars[:outputs][source_organ][source_variable(mapped_var)], MappedVar{SelfNodeMapping}) && continue - # Note: this happens when a variable is mapped to several scales, e.g. soil_water_content computed at soil scale can be - # mapped at :Leaf and :Internode scale. - - # Transforming the variable into a MappedVar pointing to itself: - self_mapped_var = (; - source_variable(mapped_var) => - MappedVar( - SelfNodeMapping(), - source_variable(mapped_var), - source_variable(mapped_var), - mapped_vars[:outputs][source_organ][source_variable(mapped_var)], - ) - ) - mapped_vars[:outputs][source_organ] = merge(mapped_vars[:outputs][source_organ], self_mapped_var) - # Note: merge overwrites the LHS values with the RHS values if they have the same key. - end - end - end - - return mapped_vars -end - -""" - get_multiscale_default_value(mapped_vars, val, mapping_stacktrace=[]) - -Get the default value of a variable from a mapping. - -# Arguments - -- `mapped_vars::Dict{Symbol,Dict{Symbol,Any}}`: the variables mapped to each organ. -- `val::Any`: the variable to get the default value of. -- `mapping_stacktrace::Vector{Any}`: the stacktrace of the search for the value in ascendind the mapping. -""" -function get_multiscale_default_value(mapped_vars, val, mapping_stacktrace=[], level=1) - if isa(val, MappedVar) && !isa(val, MappedVar{SelfNodeMapping}) - # If the value is a MappedVar, we must find the default value of the variable it is mapping into. - # Except if it is mapping to itself, in which case we return the value as is. - level += 1 - # Find the default value of the variable from the scale it is mapping into (upper scale): - m_organ = mapped_organ(val) - m_organ == Symbol("") && return mapped_default(val) # We skip the variables that are mapped to themselves (e.g. [PreviousTimeStep(:variable_name)], or just renaming a variable) - - if isa(m_organ, Symbol) - m_organ = [m_organ] - end - default_vals = [] - for o in m_organ # e.g. o = :Leaf - haskey(mapped_vars[o], source_variable(val, o)) || error("Variable `$(source_variable(val, o))` is mapped from scale `$o` to another scale, but is not computed by any model at `$o` scale.") - upper_value = mapped_vars[o][source_variable(val, o)] - push!(mapping_stacktrace, (mapped_organ=o, mapped_variable=source_variable(val, o), mapped_value=mapped_default(upper_value), level=level)) - # Recursively find the default value until the default value is not a MappedVar: - push!(default_vals, get_multiscale_default_value(mapped_vars, upper_value, mapping_stacktrace, level)) - end - - default_vals = unique(default_vals) - if length(default_vals) == 1 - return default_vals[1] - else - error( - "The variable `$(mapped_variable(val))` is mapped to several scales: $(m_organ), but the default values from the models that compute ", - "this variable at these scales is different: $(default_vals). Please make sure that the default values are the same for variable `$(mapped_variable(val))`.", - ) - end - elseif isa(val, MappedVar{SelfNodeMapping}) - return mapped_default(val) - else - return val - end -end - -""" - default_variables_from_mapping(mapped_vars, verbose=true) - -Get the default values for the mapped variables by recursively searching from the mapping to find the original mapped value. - -# Arguments - -- `mapped_vars::Dict{Symbol,Dict{Symbol,Any}}`: the variables mapped to each organ. -- `verbose::Bool`: whether to print the stacktrace of the search for the default value in the mapping. -""" -function default_variables_from_mapping(mapped_vars, verbose=true) - mapped_vars_mutable = Dict{Symbol,Dict{Symbol,Any}}(k => Dict(pairs(v)) for (k, v) in mapped_vars) - for (organ, vars) in mapped_vars # organ = :Leaf; vars = mapped_vars[organ] - for (var, val) in pairs(vars) # var = :carbon_biomass; val = getproperty(vars,var) - if isa(val, MappedVar) && !isa(val, MappedVar{SelfNodeMapping}) && mapped_organ(val) != Symbol("") - mapping_stacktrace = Any[(mapped_organ=organ, mapped_variable=var, mapped_value=mapped_default(mapped_vars[organ][var]), level=1)] - default_value = get_multiscale_default_value(mapped_vars, val, mapping_stacktrace) - mapped_vars_mutable[organ][var] = MappedVar(source_organs(val), mapped_variable(val), source_variable(val), default_value) - if verbose - @info """Default value for $(var) in $(organ) is taken from a mapping, here is the stacktrace: """ maxlog = 1 - - @info Term.Tables.Table(Tables.matrix(reverse(mapping_stacktrace)); header=["Scale", "Variable name", "Default value", "Level"]) - - @info """The stacktrace shows the step-by-step search for the default value, the last row is the value starting from the organ itself, - and going up to the upper-most model in the dependency graph (first row). The `level` column indicates the level of the search, - with 1 being the upper-most model in the dependency graph. The default value is the value found in the first row of the stacktrace, - or the unique value between common levels. - """ maxlog = 1 - end - end - end - end - return mapped_vars_mutable -end - - -""" - convert_reference_values!(mapped_vars::Dict{Symbol,Dict{Symbol,Any}}) - -Convert the variables that are `MappedVar{SelfNodeMapping}` or `MappedVar{SingleNodeMapping}` to RefValues that reference a -common value for the variable; and convert `MappedVar{MultiNodeMapping}` to RefVectors that reference the values for the -variable in the source organs. -""" -function convert_reference_values!(mapped_vars::Dict{Symbol,Dict{Symbol,Any}}) - # For the variables that will be RefValues, i.e. referencing a value that exists for different scales, we need to first - # create a common reference to the value that we use wherever we need this value. These values are created in the dict_mapped_vars - # Dict, and then referenced from there every time we point to it. - # So in essence we replace the MappedVar{SelfNodeMapping} and MappedVar{SingleNodeMapping} by a RefValue to the actual variable. - dict_mapped_vars = Dict{Pair,Any}() - - # First pass: converting the MappedVar{SelfNodeMapping} and MappedVar{SingleNodeMapping} to RefValues: - for (organ, vars) in mapped_vars # e.g.: organ = :Plant; vars = mapped_vars[organ] - for (k, v) in vars # e.g.: k = :aPPFD_larger_scale; v = vars[k] - if isa(v, MappedVar{SelfNodeMapping}) || isa(v, MappedVar{SingleNodeMapping}) - mapped_org = isa(v, MappedVar{SelfNodeMapping}) ? organ : mapped_organ(v) - mapped_org == Symbol("") && continue - key = mapped_org => source_variable(v) - - # First time we encounter this variable as a MappedVar, we create its value into the dict_mapped_vars Dict: - if !haskey(dict_mapped_vars, key) - push!(dict_mapped_vars, key => Ref(mapped_default(vars[k]))) - end - - # Then we use the value for the particular variable to replace the MappedVar to a RefValue in the mapping: - vars[k] = dict_mapped_vars[key] - end - end - end - - # Second pass: converting the MappedVar{MultiNodeMapping} to RefVectors: - for (organ, vars) in mapped_vars # e.g.: organ = :Plant; vars = mapped_vars[organ] - for (k, v) in vars # e.g.: k = :carbon_allocation; v = vars[k] - if isa(v, MappedVar{MultiNodeMapping}) - # We have to create a RefVector for the target organ: - orgs_defaults = [mapped_vars[org][source_variable(v, org)] for org in mapped_organ(v)] |> unique - - if eltype(orgs_defaults) <: Ref - orgs_defaults = [org[] for org in orgs_defaults] |> unique - end - - if length(orgs_defaults) > 1 - error( - "In organ $organ, the variable `$(mapped_variable(v))` is mapped to several scales: $(mapped_organ(v)), but the default values from the models that compute ", - "this variable at these scales are different: $(orgs_defaults) (note that `type_promotion` has been applied). ", - "Please make sure that the default values are the same for variable `$(mapped_variable(v))`." - ) - end - vars[k] = RefVector{eltype(orgs_defaults)}() - end - end - end - - # Third pass: getting the same reference for the variables that are mapped at the same scale to another variable (changing its name): - for (organ, vars) in mapped_vars # e.g.: organ = :Plant; vars = mapped_vars[organ] - for (k, v) in vars # e.g.: k = :carbon_allocation; v = vars[k] - if isa(v, MappedVar) && mapped_organ(v) == Symbol("") - mapped_var = mapped_variable(v) - isa(mapped_var, PreviousTimeStep) && (mapped_var = mapped_var.variable) - if mapped_var == source_variable(v) - # Happens when the mapping is just [PreviousTimeStep(:variable_name)] - vars[k] = mapped_default(vars[k]) - else - # If we want to rename the variable at the same scale, use a reference to the origin variable. - # Note: we use a PerStatusRef to make sure that each status has its own reference to the value (the ref is not shared between statuses). - vars[k] = RefVariable(source_variable(v)) - end - end - end - end - return mapped_vars -end diff --git a/src/mtg/mapping/getters.jl b/src/mtg/mapping/getters.jl deleted file mode 100644 index c9512c7d2..000000000 --- a/src/mtg/mapping/getters.jl +++ /dev/null @@ -1,131 +0,0 @@ -""" - get_models(m) - -Get the models of a dictionary of model mapping. - -# Arguments - -- `m`: a scale mapping entry (for example one value from a [`ModelMapping`](@ref)) - -Returns a vector of models - -# Examples - -```jldoctest mylabel -julia> using PlantSimEngine; -``` - -Import example models (can be found in the `examples` folder of the package, or in the `Examples` sub-modules): - -```jldoctest mylabel -julia> using PlantSimEngine.Examples; -``` - -If we just give a MultiScaleModel, we get its model as a one-element vector: - -```jldoctest mylabel -julia> models = MultiScaleModel( \ - model=ToyCAllocationModel(), \ - mapped_variables=[ \ - :carbon_assimilation => [:Leaf], \ - :carbon_demand => [:Leaf, :Internode], \ - :carbon_allocation => [:Leaf, :Internode] \ - ], \ - ); -``` - -```jldoctest mylabel -julia> PlantSimEngine.get_models(models) -1-element Vector{ToyCAllocationModel}: - ToyCAllocationModel() -``` - -If we give a tuple of models, we get each model in a vector: - -```jldoctest mylabel -julia> models2 = ( \ - MultiScaleModel( \ - model=ToyAssimModel(), \ - mapped_variables=[:soil_water_content => :Soil,], \ - ), \ - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), \ - Status(aPPFD=1300.0, TT=10.0), \ - ); -``` - -Notice that we provide `:Soil`, not `[:Soil]` in the mapping because a single value is expected for the mapping here. - -```jldoctest mylabel -julia> PlantSimEngine.get_models(models2) -2-element Vector{AbstractModel}: - ToyAssimModel{Float64}(0.2) - ToyCDemandModel{Float64}(10.0, 200.0) -``` -""" -get_models(m) = [model_(i) for i in m if !isa(i, Status)] - -""" - get_model_specs(m) - -Normalize model declarations to `ModelSpec`. -Plain models and `MultiScaleModel` entries are converted to `ModelSpec`. -""" -get_model_specs(m::ModelSpec) = [m] -get_model_specs(m::AbstractModel) = [as_model_spec(m)] -get_model_specs(m::MultiScaleModel) = [as_model_spec(m)] -get_model_specs(m) = [as_model_spec(i) for i in m if !isa(i, Status)] - -""" - parse_model_specs(m) - -Return a process-indexed dictionary of normalized `ModelSpec`. -""" -parse_model_specs(m) = Dict{Symbol,ModelSpec}(process(model_(spec)) => spec for spec in get_model_specs(m)) - - -# Same, for the status (if any provided): - -""" - get_status(m) - -Get the status of a dictionary of model mapping. - -# Arguments - -- `m`: a scale mapping entry (for example one value from a [`ModelMapping`](@ref)) - -Returns a [`Status`](@ref) or `nothing`. - -# Examples - -See [`get_models`](@ref) for examples. -""" -function get_status(m) - st = Status[i for i in m if isa(i, Status)] - @assert length(st) <= 1 "Only one status can be provided for each organ type." - length(st) == 0 && return nothing - return first(st) -end - -""" - get_mapped_variables(m) - -Get the mapping of a dictionary of model mapping. - -# Arguments - -- `m`: a scale mapping entry (for example one value from a [`ModelMapping`](@ref)) - -Returns a vector of mapped-variable declarations using symbol scales. - -# Examples - -See [`get_models`](@ref) for examples. -""" -function get_mapped_variables(m) - mod_mapping = [get_mapped_variables(i) for i in m if !isa(i, Status)] - if length(mod_mapping) == 0 - return Pair{Symbol,String}[] - end - return reduce(vcat, mod_mapping) |> unique -end diff --git a/src/mtg/mapping/mapping.jl b/src/mtg/mapping/mapping.jl deleted file mode 100755 index 51b34efd4..000000000 --- a/src/mtg/mapping/mapping.jl +++ /dev/null @@ -1,811 +0,0 @@ -""" - AbstractNodeMapping - -Abstract type for the type of node mapping, *e.g.* single node mapping or multiple node mapping. -""" -abstract type AbstractNodeMapping end - -@noinline function _warn_string_scale(context::Symbol) - Base.depwarn( - "String scale names are deprecated and will be removed in a future release. Use Symbol scales, e.g. `:Leaf` instead of `\"Leaf\"`.", - context - ) -end - -_normalize_scale(scale::Symbol; warn::Bool=false, context::Symbol=:PlantSimEngine) = scale -function _normalize_scale(scale::AbstractString; warn::Bool=true, context::Symbol=:PlantSimEngine) - warn && _warn_string_scale(context) - return Symbol(scale) -end - -""" - SingleNodeMapping(scale) - -Type for the single node mapping, *e.g.* `[:soil_water_content => :Soil,]`. Note that `:Soil` is given as a scalar, -which means that `:soil_water_content` will be a scalar value taken from the unique `:Soil` node in the plant graph. -""" -struct SingleNodeMapping <: AbstractNodeMapping - scale::Symbol -end - -SingleNodeMapping(scale::Union{Symbol,AbstractString}) = - SingleNodeMapping(_normalize_scale(scale; warn=scale isa AbstractString, context=:SingleNodeMapping)) - -""" - SelfNodeMapping() - -Type for the self node mapping, *i.e.* a node that maps onto itself. -This is used to flag variables that will be referenced as a scalar value by other models. It can happen in two conditions: - - the variable is computed by another scale, so we need this variable to exist as an input to this scale (it is not - computed at this scale otherwise) - - the variable is used as input to another scale but as a single value (scalar), so we need to reference it as a scalar. -""" -struct SelfNodeMapping <: AbstractNodeMapping end - -""" - MultiNodeMapping(scale) - -Type for the multiple node mapping, *e.g.* `[:carbon_assimilation => [:Leaf],]`. Note that `:Leaf` is given as a vector, -which means `:carbon_assimilation` will be a vector of values taken from each `:Leaf` in the plant graph. -""" -struct MultiNodeMapping <: AbstractNodeMapping - scale::Vector{Symbol} -end - -MultiNodeMapping(scale::Union{Symbol,AbstractString}) = MultiNodeMapping([scale]) -function MultiNodeMapping(scale::AbstractVector{<:Union{Symbol,AbstractString}}) - normalized = Symbol[ - _normalize_scale(s; warn=s isa AbstractString, context=:MultiNodeMapping) for s in scale - ] - return MultiNodeMapping(normalized) -end - -""" - MappedVar(source_organ, variable, source_variable, source_default) - -A variable mapped to another scale. - -# Arguments - -- `source_organ`: the organ(s) that are targeted by the mapping -- `variable`: the name of the variable that is mapped -- `source_variable`: the name of the variable from the source organ (the one that computes the variable) -- `source_default`: the default value of the variable - -# Examples - -```jldoctest mylabel -julia> using PlantSimEngine - -julia> PlantSimEngine.MappedVar(PlantSimEngine.SingleNodeMapping(:Leaf), :carbon_assimilation, :carbon_assimilation, 1.0) -PlantSimEngine.MappedVar{PlantSimEngine.SingleNodeMapping, Symbol, Symbol, Float64}(PlantSimEngine.SingleNodeMapping(:Leaf), :carbon_assimilation, :carbon_assimilation, 1.0) -``` -""" -struct MappedVar{O<:AbstractNodeMapping,V1<:Union{Symbol,PreviousTimeStep},V2<:Union{S,Vector{S}} where {S<:Symbol},T} - source_organ::O - variable::V1 - source_variable::V2 - source_default::T -end - -mapped_variable(m::MappedVar) = m.variable -source_organs(m::MappedVar) = m.source_organ -source_organs(m::MappedVar{O,V1,V2,T}) where {O<:AbstractNodeMapping,V1,V2,T} = nothing -mapped_organ(m::MappedVar{O,V1,V2,T}) where {O,V1,V2,T} = source_organs(m).scale -mapped_organ(m::MappedVar{O,V1,V2,T}) where {O<:SelfNodeMapping,V1,V2,T} = nothing -mapped_organ_type(m::MappedVar{O,V1,V2,T}) where {O<:AbstractNodeMapping,V1,V2,T} = O -source_variable(m::MappedVar) = m.source_variable -function source_variable(m::MappedVar{O,V1,V2,T}, organ) where {O<:SingleNodeMapping,V1,V2<:Symbol,T} - @assert organ == mapped_organ(m) "Organ $organ not found in the mapping of the variable $(mapped_variable(m))." - m.source_variable -end - -function source_variable(m::MappedVar{O,V1,V2,T}, organ) where {O<:MultiNodeMapping,V1,V2<:Vector{Symbol},T} - @assert organ in mapped_organ(m) "Organ $organ not found in the mapping of the variable $(mapped_variable(m))." - m.source_variable[findfirst(o -> o == organ, mapped_organ(m))] -end - -mapped_default(m::MappedVar) = m.source_default -mapped_default(m::MappedVar{O,V1,V2,T}, organ) where {O<:MultiNodeMapping,V1,V2<:Vector{Symbol},T} = m.source_default[findfirst(o -> o == organ, mapped_organ(m))] -mapped_default(m) = m # For any variable that is not a MappedVar, we return it as is - -# This defines the type of mapping setup: either single or multiscale. Used to dispatch methods for e.g. `dep` or `to_initialize`. -abstract type AbstractScaleSetup end - -struct MultiScale <: AbstractScaleSetup end -struct SingleScale <: AbstractScaleSetup end - -""" - ModelMappingInfo - -Cached metadata computed at `ModelMapping` construction time to avoid repeated -normalization/introspection work across runtime entrypoints. -""" -struct ModelMappingInfo - validated::Bool - is_valid::Bool - is_multirate::Bool - scales::Vector{Symbol} - models_per_scale::Dict{Symbol,Int} - processes_per_scale::Dict{Symbol,Vector{Symbol}} - declared_rates::Dict{Symbol,Any} - vars_need_init::Any - model_specs::Dict{Symbol,Dict{Symbol,ModelSpec}} - recommendations::Vector{String} -end - -function _empty_model_mapping_info() - ModelMappingInfo( - false, - false, - false, - Symbol[], - Dict{Symbol,Int}(), - Dict{Symbol,Vector{Symbol}}(), - Dict{Symbol,Any}(), - NamedTuple(), - Dict{Symbol,Dict{Symbol,ModelSpec}}(), - String[], - ) -end - -""" - ModelMapping(mapping; check=true) - -Validated mapping between MTG scales and model definitions. - -Each scale entry may be provided as: -- a single model, -- a tuple of models with an optional [`Status`](@ref), - -At construction time, the mapping is normalized and checked to fail early on common -configuration errors: -- each scale must define at least one model, -- at most one `Status` is allowed per scale, -- mapped scales must exist in the mapping, -- mapped source variables must exist on the source scale (as a model output or status variable), -- duplicate process declarations at a given scale are rejected. - -# Notes - -The type behaves like a read-only dictionary keyed by scale name (`Symbol`). -Use `Dict(mapping)` to recover a plain dictionary. -""" -struct ModelMapping{S<:AbstractScaleSetup,D} <: AbstractDict{Symbol,Tuple} where {D<:Union{Dict{Symbol,Tuple},ModelList}} - data::D - info::ModelMappingInfo - type_promotion::Union{Nothing,Dict} -end - -function _build_model_mapping(::Type{S}, data; validated::Bool, type_promotion::Union{Nothing,Dict}=nothing) where {S<:AbstractScaleSetup} - info = try - _build_model_mapping_info(S, data; validated=validated) - catch - _empty_model_mapping_info() - end - ModelMapping{S,typeof(data)}(data, info, type_promotion) -end - -ModelMapping{S}(data) where {S<:AbstractScaleSetup} = _build_model_mapping(S, data; validated=false) - -""" - model_rate(model::AbstractModel) - -Optional model hook used by [`ModelMapping`](@ref) to check rate compatibility. - -By default it returns `nothing` (no explicit rate contract). Package users can provide -model-specific methods that return a comparable value (for example `Dates.Period`), and -`ModelMapping` will reject incompatible mapped couplings. -""" -model_rate(::AbstractModel) = nothing -model_rate(model::MultiScaleModel) = model_rate(model_(model)) - -Base.length(mapping::ModelMapping{MultiScale}) = length(mapping.data) -Base.length(::ModelMapping{SingleScale}) = 1 -Base.iterate(mapping::ModelMapping{MultiScale}, state...) = iterate(mapping.data, state...) -# Base.iterate(mapping::ModelMapping{SingleScale}, state...) = iterate(mapping.data.models, state...) -function Base.show(io::IO, mapping::ModelMapping) - print( - io, - "ModelMapping(", - length(mapping.info.scales), - " scale", - length(mapping.info.scales) == 1 ? "" : "s", - ", multirate=", - mapping.info.is_multirate, - ")" - ) -end - -function _isempty_vars_need_init(vars_need_init) - vars_need_init isa NamedTuple && return isempty(keys(vars_need_init)) - vars_need_init isa AbstractDict && return all(isempty, values(vars_need_init)) - vars_need_init isa AbstractVector && return isempty(vars_need_init) - return isnothing(vars_need_init) -end - -function _timing_group_label_for_spec(spec::ModelSpec) - if !isnothing(timestep(spec)) - return string("explicit ", timestep(spec), " (ModelSpec)") - end - - model_clock = timespec(model_(spec)) - if !_is_default_clock(model_clock) - return string("model timespec ", model_clock) - end - - return "meteo base step (inferred at runtime)" -end - -function _model_timing_groups(info::ModelMappingInfo) - groups = Dict{String,Int}() - for specs_at_scale in values(info.model_specs), spec in values(specs_at_scale) - label = _timing_group_label_for_spec(spec) - groups[label] = get(groups, label, 0) + 1 - end - return groups -end - -function _show_model_mapping_plain(io::IO, mapping::ModelMapping) - info = mapping.info - println(io, "ModelMapping") - println(io, " validated: ", info.validated, " (", info.is_valid ? "valid" : "invalid", ")") - println(io, " multirate: ", info.is_multirate) - println(io, " scales (", length(info.scales), "): ", join(info.scales, ", ")) - for scale in info.scales - print(io, " - ", scale, ": ", get(info.models_per_scale, scale, 0), " model(s)") - processes = get(info.processes_per_scale, scale, Symbol[]) - if !isempty(processes) - print(io, ", Processes=", join(string.(processes), ", ")) - end - rate = get(info.declared_rates, scale, nothing) - if !isnothing(rate) - print(io, ", Rate=", rate) - end - println(io) - end - timing_groups = _model_timing_groups(info) - if !isempty(timing_groups) - println(io, " Timing groups:") - for label in sort!(collect(keys(timing_groups)); by=string) - println(io, " - ", label, ": ", timing_groups[label], " model(s)") - end - println(io, " Get resolved timings with: `effective_rate_summary(modelmapping, meteo)`") - end - if _isempty_vars_need_init(info.vars_need_init) - println(io, " Variables to initialize: none") - else - println(io, " Variables to initialize: ", info.vars_need_init) - end - if !isempty(info.recommendations) - println(io, " Recommendations:") - for recommendation in info.recommendations - println(io, " - ", recommendation) - end - end -end - -function Base.show(io::IO, m::MIME"text/plain", mapping::ModelMapping) - if get(io, :compact, false) - return show(io, mapping) - end - _show_model_mapping_plain(io, mapping) - if mapping isa ModelMapping{SingleScale} - println(io, " status:") - show(io, m, mapping.data) - end -end - -Base.keys(mapping::ModelMapping) = keys(mapping.data) -Base.values(mapping::ModelMapping) = values(mapping.data) -Base.pairs(mapping::ModelMapping) = pairs(mapping.data) -Base.keys(::ModelMapping{SingleScale}) = (:Default,) -Base.values(mapping::ModelMapping{SingleScale}) = ((values(mapping.data.models)..., status(mapping.data)),) -Base.pairs(mapping::ModelMapping{SingleScale}) = (:Default => (values(mapping.data.models)..., status(mapping.data)),) -Base.getindex(mapping::ModelMapping, key::Symbol) = mapping.data[key] -function Base.getindex(mapping::ModelMapping, key::AbstractString) - sym = _normalize_scale(key; warn=true, context=:ModelMapping) - return mapping.data[sym] -end -function Base.getindex(mapping::ModelMapping{SingleScale}, key::Symbol) - if key == :Default - return (values(mapping.data.models)..., status(mapping.data)) - end - return getindex(mapping.data, key) -end -Base.getindex(mapping::ModelMapping{SingleScale}, key::AbstractString) = getindex(mapping, _normalize_scale(key; warn=true, context=:ModelMapping)) -Base.getindex(mapping::ModelMapping{SingleScale}, key::Integer) = getindex(mapping.data, key) -Base.haskey(mapping::ModelMapping, key::Symbol) = haskey(mapping.data, key) -Base.haskey(mapping::ModelMapping, key::AbstractString) = haskey(mapping.data, _normalize_scale(key; warn=true, context=:ModelMapping)) -Base.eltype(::Type{ModelMapping}) = Pair{Symbol,Tuple} -Base.copy(mapping::ModelMapping{MultiScale}) = _build_model_mapping(MultiScale, copy(mapping.data); validated=mapping.info.validated, type_promotion=deepcopy(mapping.type_promotion)) -Base.copy(mapping::ModelMapping{SingleScale}) = _build_model_mapping(SingleScale, copy(mapping.data); validated=mapping.info.validated, type_promotion=deepcopy(mapping.type_promotion)) -Base.copy(mapping::ModelMapping{SingleScale}, status) = _build_model_mapping(SingleScale, copy(mapping.data, status); validated=mapping.info.validated, type_promotion=deepcopy(mapping.type_promotion)) -Base.Dict(mapping::ModelMapping) = copy(mapping.data) -Base.:(==)(left::ModelMapping{SingleScale}, right::ModelMapping{SingleScale}) = left.data == right.data && left.type_promotion == right.type_promotion - -function Base.getproperty(mapping::ModelMapping{SingleScale}, name::Symbol) - (name === :data || name === :info || name === :type_promotion) && return getfield(mapping, name) - return getproperty(getfield(mapping, :data), name) -end - -function ModelMapping{MultiScale}(mapping::T; check::Bool=true, type_promotion::Union{Nothing,Dict}=nothing) where {T<:AbstractDict} - normalized = _normalize_multiscale_mapping(mapping) - check && _check_multiscale_mapping!(normalized) - _build_model_mapping(MultiScale, normalized; validated=check, type_promotion=type_promotion) -end - -ModelMapping(mapping::AbstractDict; check::Bool=true, type_promotion::Union{Nothing,Dict}=nothing) = - ModelMapping{MultiScale}(mapping; check=check, type_promotion=type_promotion) - -function ModelMapping(mapping::ModelMapping{MultiScale}; check::Bool=true, type_promotion::Union{Nothing,Dict}=mapping.type_promotion) - if check || type_promotion != mapping.type_promotion - return ModelMapping(mapping.data; check=check, type_promotion=type_promotion) - end - return mapping -end - -function ModelMapping(mapping::ModelMapping{SingleScale}; check::Bool=true, type_promotion::Union{Nothing,Dict}=mapping.type_promotion) - if type_promotion != mapping.type_promotion - error("Cannot change `type_promotion` on an already constructed single-scale `ModelMapping`; rebuild it from models and status.") - end - return check ? _build_model_mapping(SingleScale, mapping.data; validated=true, type_promotion=mapping.type_promotion) : mapping -end - -""" - ModelMapping(scale_mapping_pairs...; check=true, type_promotion=nothing) - ModelMapping(models...; scale=:Default, status=nothing, type_promotion=nothing, check=true, processes...) - -Convenience constructors for [`ModelMapping`](@ref): - -- pass `scale => models` pairs directly (dict-like syntax), -- or pass models/processes directly for a single scale (old `ModelList` syntax). - -`type_promotion` may be a dictionary of source type to target type, for example -`Dict(Float64 => Float32)`. In single-scale mappings it is applied when the -backing status is constructed. In multiscale mappings it is stored on the -mapping and applied when a `GraphSimulation` is initialized. -""" -function ModelMapping( - args...; - scale::Union{Symbol,AbstractString}=:Default, - status=nothing, - type_promotion::Union{Nothing,Dict}=nothing, - check::Bool=true, - processes... -) - isempty(args) && isempty(processes) && error( - "No mapping or model was provided. Use `ModelMapping(\"Scale\" => models)` or pass models directly." - ) - - # Backwards compatibility: allow dict-like construction for type promotion maps, - # e.g. `ModelMapping(Float64 => Float32)`. - if !isempty(args) && all(arg -> arg isa Pair && !(first(arg) isa Union{AbstractString,Symbol}), args) - return Dict(args) - end - - if _all_scale_pairs(args) - isempty(processes) || error( - "Cannot mix scale-level pairs with process keyword arguments. ", - "Use either `\"Scale\" => models` pairs, or single-scale process/model arguments." - ) - isnothing(status) || error( - "`status` cannot be used with scale-level pair syntax. ", - "Provide statuses inside each scale mapping instead." - ) - raw_mapping = Dict{Symbol,Any}( - _normalize_scale(first(pair); warn=first(pair) isa AbstractString, context=:ModelMapping) => last(pair) - for pair in args - ) - return ModelMapping{MultiScale}(raw_mapping; check=check, type_promotion=type_promotion) - end - - _contains_scale_like_pair(args) && error( - "Invalid argument mix: scale-level pairs must not be mixed with model arguments." - ) - - flat_args = Any[] - for arg in args - if arg isa Pair && first(arg) isa Symbol - push!(flat_args, last(arg)) - elseif arg isa NamedTuple - append!(flat_args, values(arg)) - elseif arg isa Tuple - append!(flat_args, arg) - else - push!(flat_args, arg) - end - end - - return _build_model_mapping( - SingleScale, - ModelList(flat_args...; status=status, type_promotion=type_promotion, variables_check=check, processes...); - validated=check, - type_promotion=type_promotion - ) - - #TODO: Use the following when we merge the ModelList and ModelMapping paths (create a fake scale): - single_scale_models = _single_scale_mapping_entries(args, processes, status) - # return ModelMapping{SingleScale}(Dict(_normalize_scale(scale) => single_scale_models), check=check) -end - -# Canonical API dispatches for model mappings. -dep(mapping::ModelMapping{SingleScale}; verbose::Bool=true) = dep(mapping.data) -dep(mapping::ModelMapping{MultiScale}; verbose::Bool=true) = dep(mapping.data; verbose=verbose) -hard_dependencies(mapping::ModelMapping{SingleScale}; verbose::Bool=true) = hard_dependencies(mapping.data) -hard_dependencies(mapping::ModelMapping{MultiScale}; verbose::Bool=true) = hard_dependencies(mapping.data; verbose=verbose) -inputs(mapping::ModelMapping) = inputs(mapping.data) -outputs(mapping::ModelMapping) = outputs(mapping.data) -variables(mapping::ModelMapping) = variables(mapping.data) -function to_initialize(mapping::ModelMapping, graph=nothing) - isnothing(graph) && return mapping.info.vars_need_init - return to_initialize(mapping.data, graph) -end -reverse_mapping(mapping::ModelMapping; all=true) = reverse_mapping(mapping.data; all=all) -init_variables(mapping::ModelMapping{SingleScale}; verbose=true) = init_variables(mapping.data; verbose=verbose) -to_initialize(mapping::ModelMapping{SingleScale}) = mapping.info.vars_need_init -to_initialize(mapping::ModelMapping{SingleScale}, graph) = to_initialize(mapping) -pre_allocate_outputs(mapping::ModelMapping{SingleScale}, outs, nsteps; type_promotion=nothing, check=true) = - pre_allocate_outputs(mapping.data, outs, nsteps; type_promotion=type_promotion, check=check) - -mapping_info(mapping::ModelMapping) = mapping.info -is_multirate(mapping::ModelMapping) = mapping.info.is_multirate -is_valid(mapping::ModelMapping) = mapping.info.is_valid -type_promotion(mapping::ModelMapping) = mapping.type_promotion -type_promotion(::Any) = nothing -_type_promotion(mapping) = type_promotion(mapping) - -function _all_scale_pairs(args) - !isempty(args) && all(arg -> arg isa Pair && first(arg) isa Union{AbstractString,Symbol}, args) -end - -function _contains_scale_like_pair(args) - any(arg -> arg isa Pair && first(arg) isa Union{AbstractString,Symbol}, args) -end - -function _single_scale_mapping_entries(args, processes, status) - models = Any[] - - for arg in args - if arg isa Pair && first(arg) isa Symbol - push!(models, last(arg)) - elseif arg isa NamedTuple - append!(models, values(arg)) - elseif arg isa Tuple - append!(models, arg) - else - push!(models, arg) - end - end - - append!(models, values(processes)) - - if !isnothing(status) - status_entry = status isa Status ? status : Status(status) - push!(models, status_entry) - end - - return tuple(models...) -end - -function _normalize_multiscale_mapping(mapping::AbstractDict) - isempty(mapping) && error("ModelMapping cannot be empty. Provide at least one scale with models.") - normalized = Dict{Symbol,Tuple}() - for (scale, scale_mapping) in pairs(mapping) - scale_name = _normalize_scale(scale; warn=scale isa AbstractString, context=:ModelMapping) - normalized[scale_name] = _normalize_scale_mapping(scale_name, scale_mapping) - end - return normalized -end - -function _normalize_scale_mapping(scale::Symbol, scale_mapping::ModelList) - return _normalize_scale_mapping(scale, (values(scale_mapping.models)..., status(scale_mapping))) -end - -function _normalize_scale_mapping(scale::Symbol, scale_mapping::ModelMapping{SingleScale}) - return _normalize_scale_mapping(scale, scale_mapping.data) -end - -function _normalize_scale_mapping(scale::Symbol, scale_mapping::Union{AbstractModel,MultiScaleModel,ModelSpec}) - return (scale_mapping,) -end - -function _normalize_scale_mapping(scale::Symbol, scale_mapping::Tuple) - normalized_items = Any[] - for item in scale_mapping - if item isa ModelList - append!(normalized_items, values(item.models)) - push!(normalized_items, status(item)) - elseif item isa Union{AbstractModel,MultiScaleModel,ModelSpec,Status} - push!(normalized_items, item) - else - error( - "Invalid mapping entry at scale `$scale`: expected models/ModelSpec, Status, or ModelList, got $(typeof(item))." - ) - end - end - return tuple(normalized_items...) -end - -function _normalize_scale_mapping(scale::Symbol, scale_mapping) - error( - "Invalid mapping entry at scale `$scale`: expected a model/ModelSpec, tuple of models/Status, or ModelList, got $(typeof(scale_mapping))." - ) -end - -function _check_multiscale_mapping!(mapping::Dict{Symbol,Tuple}) - _check_scales_have_models!(mapping) - _check_scale_process_uniqueness!(mapping) - _check_mapped_sources_exist!(mapping) - return mapping -end - -function _check_scales_have_models!(mapping::Dict{Symbol,Tuple}) - for (scale, scale_mapping) in mapping - n_status = count(item -> item isa Status, scale_mapping) - n_status > 1 && error("Scale `$scale` defines $n_status statuses. Only one Status is allowed per scale.") - - models = get_models(scale_mapping) - isempty(models) && error( - "Scale `$scale` defines no model. Add at least one model, or remove this scale from the mapping." - ) - end -end - -function _check_scale_process_uniqueness!(mapping::Dict{Symbol,Tuple}) - for (scale, scale_mapping) in mapping - process_names = [_process_name_for_mapping_check(model) for model in get_models(scale_mapping)] - duplicates = unique(filter(p -> count(==(p), process_names) > 1, process_names)) - isempty(duplicates) && continue - duplicate_names = join(string.(duplicates), ", ") - error( - "Scale `$scale` defines duplicate process(es): $duplicate_names. ", - "Keep only one model per process at a given scale (or use hard dependencies)." - ) - end -end - -function _process_name_for_mapping_check(model) - try - return process(model) - catch - return Symbol(nameof(typeof(model))) - end -end - -function _check_mapped_sources_exist!(mapping::Dict{Symbol,Tuple}) - available_variables = _available_variables_by_scale(mapping) - scale_rates = _declared_model_rates_by_scale(mapping) - - for (target_scale, scale_mapping) in mapping - for item in scale_mapping - mapped_vars = _mapping_item_mapped_variables(item) - isempty(mapped_vars) && continue - - base_model = _mapping_item_model(item) - model_inputs = Set(keys(inputs_(base_model))) - model_outputs = Set(keys(outputs_(base_model))) - for mapped_var in mapped_vars - mapped_variable_name = first(mapped_var) isa PreviousTimeStep ? first(mapped_var).variable : first(mapped_var) - checks_source_value = (mapped_variable_name in model_inputs) && !(mapped_variable_name in model_outputs) - - for (source_scale_raw, source_variable) in _as_mapping_sources(last(mapped_var)) - source_scale = isnothing(source_scale_raw) ? target_scale : source_scale_raw - - haskey(mapping, source_scale) || error( - "Scale `$target_scale` maps variable `$(first(mapped_var))` to missing scale `$source_scale`. ", - "Add `$source_scale` to ModelMapping, or fix the mapped scale name." - ) - - if checks_source_value && source_variable ∉ available_variables[source_scale] - error( - "Scale `$target_scale` maps variable `$(first(mapped_var))` to `$source_scale.$source_variable`, ", - "but `$source_variable` is not available at scale `$source_scale` ", - "(neither model output, Status variable, nor mapped output from another scale). ", - "Define a model output for `$source_variable`, initialize it in the source scale Status, ", - "or map this variable from another scale into `$source_scale` before using it." - ) - end - - if checks_source_value && !_rates_compatible(scale_rates[target_scale], scale_rates[source_scale]) - error( - "Scale `$target_scale` declares model rate $(scale_rates[target_scale]) but maps input `$(first(mapped_var))` ", - "from scale `$source_scale` with model rate $(scale_rates[source_scale]). ", - "Align model rates between scales or remove explicit `model_rate` declarations." - ) - end - end - end - end - end -end - -_mapping_item_mapped_variables(item::ModelSpec) = mapped_variables_(item) -_mapping_item_mapped_variables(item::MultiScaleModel) = mapped_variables_(item) -_mapping_item_mapped_variables(::Any) = Pair{Symbol,Symbol}[] - -_mapping_item_model(item::ModelSpec) = model_(item) -_mapping_item_model(item::MultiScaleModel) = model_(item) - -function _as_mapping_scale(source_scale::AbstractString) - isempty(source_scale) && return nothing - return _normalize_scale(source_scale; warn=true, context=:ModelMapping) -end - -function _as_mapping_scale(source_scale::Symbol) - source_scale === Symbol("") && return nothing - return _normalize_scale(source_scale; warn=false, context=:ModelMapping) -end - -_as_mapping_sources(source::Pair{<:Union{AbstractString,Symbol},Symbol}) = - (_as_mapping_scale(first(source)) => last(source),) -_as_mapping_sources(source::AbstractVector{<:Pair{<:Union{AbstractString,Symbol},Symbol}}) = - Tuple(_as_mapping_scale(first(item)) => last(item) for item in source) - -function _available_variables_by_scale(mapping::Dict{Symbol,Tuple}) - available = Dict{Symbol,Set{Symbol}}() - for (scale, scale_mapping) in mapping - vars = Set{Symbol}() - for model in get_models(scale_mapping) - union!(vars, keys(outputs_(model))) - end - st = get_status(scale_mapping) - !isnothing(st) && union!(vars, keys(st)) - available[scale] = vars - end - - # Variables produced at one scale and explicitly mapped as outputs to another - # scale are available at the target scale as runtime references. - for (source_scale, scale_mapping) in mapping - for item in scale_mapping - mapped_vars = _mapping_item_mapped_variables(item) - isempty(mapped_vars) && continue - base_model = _mapping_item_model(item) - model_outputs = Set(keys(outputs_(base_model))) - for mapped_var in mapped_vars - mapped_variable_name = first(mapped_var) isa PreviousTimeStep ? first(mapped_var).variable : first(mapped_var) - mapped_variable_name in model_outputs || continue - for (target_scale_raw, target_variable) in _as_mapping_sources(last(mapped_var)) - target_scale = isnothing(target_scale_raw) ? source_scale : target_scale_raw - haskey(available, target_scale) || continue - push!(available[target_scale], target_variable) - end - end - end - end - - return available -end - -function _declared_model_rates_by_scale(mapping::Dict{Symbol,Tuple}) - rates = Dict{Symbol,Any}() - for (scale, scale_mapping) in mapping - declared_rates = unique(filter(!isnothing, map(model_rate, get_models(scale_mapping)))) - if length(declared_rates) > 1 - error( - "Scale `$scale` declares incompatible model rates $(declared_rates). ", - "Use a single rate per scale, or leave `model_rate` undefined (`nothing`)." - ) - end - rates[scale] = isempty(declared_rates) ? nothing : only(declared_rates) - end - return rates -end - -_rates_compatible(rate1, rate2) = isnothing(rate1) || isnothing(rate2) || rate1 == rate2 - -function _spec_declares_multirate(spec::ModelSpec) - model = model_(spec) - !isnothing(timestep(spec)) && return true - # Explicit input bindings are also used for same-rate disambiguation, - # so they must not, by themselves, force multirate runtime. - !isnothing(meteo_window(spec)) && return true - !isempty(keys(output_routing(spec))) && return true - timespec(model) != ClockSpec(1.0, 0.0) && return true - return false -end - -function _mapping_declares_multirate(model_specs::Dict{Symbol,Dict{Symbol,ModelSpec}}, declared_rates::Dict{Symbol,Any}) - any(!isnothing, values(declared_rates)) && return true - for specs_at_scale in values(model_specs), spec in values(specs_at_scale) - _spec_declares_multirate(spec) && return true - end - return false -end - -function _model_summary_from_mapping(mapping::Dict{Symbol,Tuple}) - models_per_scale = Dict{Symbol,Int}() - processes_per_scale = Dict{Symbol,Vector{Symbol}}() - for (scale, scale_mapping) in mapping - models = get_models(scale_mapping) - models_per_scale[scale] = length(models) - processes_per_scale[scale] = [_process_name_for_mapping_check(model) for model in models] - end - return models_per_scale, processes_per_scale -end - -function _parse_model_specs_from_mapping(mapping::Dict{Symbol,Tuple}) - Dict(scale => parse_model_specs(scale_mapping) for (scale, scale_mapping) in mapping) -end - -function _build_model_mapping_recommendations( - validated::Bool, - is_multirate::Bool, - vars_need_init -) - recommendations = String[] - if !validated - push!(recommendations, "Built with `check=false`: rebuild with `check=true` to validate consistency.") - end - if !_isempty_vars_need_init(vars_need_init) - push!(recommendations, "Initialize required variables listed above (see `to_initialize(mapping)`).") - end - if is_multirate - push!(recommendations, "Multirate is enabled from mapping metadata; `run!(mtg, mapping, ...)` auto-detects it.") - end - return recommendations -end - -function _build_model_mapping_info(::Type{SingleScale}, mapping::ModelList; validated::Bool) - specs = Dict( - :Default => Dict{Symbol,ModelSpec}( - process(model) => as_model_spec(model) for model in values(mapping.models) - ) - ) - - declared_rates = Dict{Symbol,Any}(:Default => nothing) - vars_need_init = try - to_initialize(mapping) - catch - NamedTuple() - end - is_multirate = false - recommendations = _build_model_mapping_recommendations(validated, is_multirate, vars_need_init) - processes = [process(model) for model in values(mapping.models)] - return ModelMappingInfo( - validated, - validated, - is_multirate, - [:Default], - Dict(:Default => length(mapping.models)), - Dict(:Default => processes), - declared_rates, - vars_need_init, - specs, - recommendations, - ) -end - -function _build_model_mapping_info(::Type{MultiScale}, mapping::Dict{Symbol,Tuple}; validated::Bool) - scales = collect(keys(mapping)) - models_per_scale, processes_per_scale = _model_summary_from_mapping(mapping) - declared_rates = try - _declared_model_rates_by_scale(mapping) - catch - Dict{Symbol,Any}(scale => nothing for scale in scales) - end - model_specs = try - _parse_model_specs_from_mapping(mapping) - catch - Dict{Symbol,Dict{Symbol,ModelSpec}}() - end - vars_need_init = try - to_initialize(mapping, nothing) - catch - Dict{Symbol,Vector{Symbol}}() - end - is_multirate = _mapping_declares_multirate(model_specs, declared_rates) - recommendations = _build_model_mapping_recommendations(validated, is_multirate, vars_need_init) - return ModelMappingInfo( - validated, - validated, - is_multirate, - scales, - models_per_scale, - processes_per_scale, - declared_rates, - vars_need_init, - model_specs, - recommendations, - ) -end diff --git a/src/mtg/mapping/model_generation_from_status_vectors.jl b/src/mtg/mapping/model_generation_from_status_vectors.jl deleted file mode 100644 index 76cb22901..000000000 --- a/src/mtg/mapping/model_generation_from_status_vectors.jl +++ /dev/null @@ -1,277 +0,0 @@ -# Utilities to handle vectors passed into a mapping's statuses -# This is a convenience when prototyping, not recommended for production or proper fitting -# (Write the actual finalised model explicitely instead) - - -# Status vectors are turned into regular runtime models so they can participate in -# dependency inference without relying on top-level eval or world-age-sensitive -# method generation. - -# There will still be brittleness given that it's not trivial to handle user/modeler errors : -# For instance, providing a vector that is called in a scale mapping is likely to cause things to go badly - -# May need some more complex timestep models in the future -# TODO : unhandled case : what if the timestep models are already in the provided modellist ? - -# These models might be worth exposing in the future ? -PlantSimEngine.@process "basic_current_timestep" verbose = false - -struct HelperCurrentTimestepModel <: AbstractBasic_Current_TimestepModel -end - -PlantSimEngine.inputs_(::HelperCurrentTimestepModel) = (next_timestep=1,) -PlantSimEngine.outputs_(m::HelperCurrentTimestepModel) = (current_timestep=1,) - -function PlantSimEngine.run!(m::HelperCurrentTimestepModel, models, status, meteo, constants=nothing, extra=nothing) - status.current_timestep = status.next_timestep - end - - PlantSimEngine.ObjectDependencyTrait(::Type{<:HelperCurrentTimestepModel}) = PlantSimEngine.IsObjectDependent() - PlantSimEngine.TimeStepDependencyTrait(::Type{<:HelperCurrentTimestepModel}) = PlantSimEngine.IsTimeStepDependent() - - PlantSimEngine.@process "basic_next_timestep" verbose = false - struct HelperNextTimestepModel <: AbstractBasic_Next_TimestepModel - end - - PlantSimEngine.inputs_(::HelperNextTimestepModel) = (current_timestep=1,) - PlantSimEngine.outputs_(m::HelperNextTimestepModel) = (next_timestep=1,) - - function PlantSimEngine.run!(m::HelperNextTimestepModel, models, status, meteo, constants=nothing, extra=nothing) - status.next_timestep = status.current_timestep + 1 - end - -PlantSimEngine.ObjectDependencyTrait(::Type{<:HelperNextTimestepModel}) = PlantSimEngine.IsObjectDependent() -PlantSimEngine.TimeStepDependencyTrait(::Type{<:HelperNextTimestepModel}) = PlantSimEngine.IsTimeStepDependent() - -struct GeneratedStatusVectorModel{V<:AbstractVector} <: AbstractModel - process_name::Symbol - output_name::Symbol - values::V -end - -process(model::GeneratedStatusVectorModel) = model.process_name -PlantSimEngine.inputs_(::GeneratedStatusVectorModel) = (current_timestep=1,) -PlantSimEngine.outputs_(model::GeneratedStatusVectorModel) = NamedTuple{(model.output_name,)}((first(model.values),)) - -function PlantSimEngine.run!(model::GeneratedStatusVectorModel, models, status, meteo, constants=nothing, extra_args=nothing) - status[model.output_name] = model.values[status.current_timestep] -end - -PlantSimEngine.ObjectDependencyTrait(::Type{<:GeneratedStatusVectorModel}) = PlantSimEngine.IsObjectDependent() -PlantSimEngine.TimeStepDependencyTrait(::Type{<:GeneratedStatusVectorModel}) = PlantSimEngine.IsTimeStepDependent() - - -# TODO should the new_status be copied ? -# Note : User specifies at which level they want the basic timestep model to be inserted at, as well as the meteo length -function replace_mapping_status_vectors_with_generated_models(mapping_with_vectors_in_status, timestep_model_organ_level, nsteps) - timestep_model_organ_level = _normalize_scale( - timestep_model_organ_level; - warn=timestep_model_organ_level isa AbstractString, - context=:ModelMapping - ) - - (organ, check) = check_statuses_contain_no_remaining_vectors(mapping_with_vectors_in_status) - if check - @warn "No vectors, or types deriving from AbstractVector found in statuses, returning mapping as is." - return mapping_with_vectors_in_status isa ModelMapping ? mapping_with_vectors_in_status : ModelMapping(mapping_with_vectors_in_status) - end - - # we are now certain a model will be generated, and that the timestep models need to be inserted - mapping = Dict( - _normalize_scale(organ; warn=organ isa AbstractString, context=:ModelMapping) => models - for (organ, models) in mapping_with_vectors_in_status - ) - for (organ,models) in mapping - for status in models - if isa(status, Status) - # Generate models and remove corresponding vectors from status - new_status, generated_models = generate_model_from_status_vector_variable(mapping, timestep_model_organ_level, status, organ, nsteps) - - # Avoid inserting empty named tuples into the mapping - models_and_new_status = [model for model in models if !isa(model, Status)] - if length(new_status) != 0 - models_and_new_status = [models_and_new_status..., new_status] - end - - # The timestep models might be inserted elsewhere in the mapping, handle various cases - if length(generated_models) > 0 - mapping[organ] = ( - generated_models..., - models_and_new_status...,) - end - end - end - - # insert timestep models wherever they're required - if organ == timestep_model_organ_level - # mapping at a given level can be a tuple or a single model - if isa(mapping[organ], AbstractModel) || isa(mapping[organ], MultiScaleModel) || isa(mapping[organ], ModelSpec) - mapping[organ] = ( - HelperNextTimestepModel(), - MultiScaleModel( - model=HelperCurrentTimestepModel(), - mapped_variables=[PreviousTimeStep(:next_timestep),], - ), - mapping[organ], ) - else - mapping[organ] = ( - HelperNextTimestepModel(), - MultiScaleModel( - model=HelperCurrentTimestepModel(), - mapped_variables=[PreviousTimeStep(:next_timestep),], - ), - mapping[organ]..., ) - end - end - end - - return ModelMapping(mapping) -end - -function generate_model_from_status_vector_variable(mapping, timestep_scale, status, organ, nsteps) - timestep_scale = _normalize_scale(timestep_scale; warn=timestep_scale isa AbstractString, context=:ModelMapping) - organ = _normalize_scale(organ; warn=organ isa AbstractString, context=:ModelMapping) - - # Ah, another point that remains to be seen is that those CSV.SentinelArrays.ChainedVector obtained from the meteo file isn't an AbstractVector - # meaning currently we won't generate models from them unless the conversion is made before that - # So another minor potential improvement would be to return a warning to the user and do the conversion when generating the model - # See the test code in test-mapping.jl : cumsum(meteo_day.TT) returns such a data structure - - generated_models = Any[] - new_status_names = Symbol[] - new_status_values = Any[] - - for symbol in keys(status) - value = getproperty(status, symbol) - if isa(value, AbstractVector) - @assert length(value) > 0 "Error during generation of models from vector values provided at the $organ-level status : provided $symbol vector is empty" - # TODO : Might need to fiddle with timesteps here in the future in case of varying timestep models - @assert nsteps == length(value) "Error during generation of models from vector values provided at the $organ-level status : provided $symbol vector length doesn't match the expected # of timesteps" - - process_name = Symbol(lowercase(string(symbol) * bytes2hex(sha1(repr(value))))) - model = GeneratedStatusVectorModel(process_name, symbol, value) - - # if :current_timestep is not in the same scale - if timestep_scale != organ - push!( - generated_models, - MultiScaleModel( - model=model, - mapped_variables=[:current_timestep => (timestep_scale => :current_timestep)], - ) - ) - else - push!(generated_models, model) - end - else - push!(new_status_names, symbol) - push!(new_status_values, value) - end - end - - new_status = Status(NamedTuple{Tuple(new_status_names)}(Tuple(new_status_values))) - generated_models_tuple = Tuple(generated_models) - - @assert length(status) == length(new_status) + length(generated_models_tuple) "Error during generation of models from vector values provided at the $organ-level status" - return new_status, generated_models_tuple -end - - -# This is a helper function only for testing purposes. -function modellist_to_mapping(modellist_original::ModelList, modellist_status; nsteps=nothing, outputs=nothing) - - modellist = Base.copy(modellist_original, modellist_original.status) - - default_scale = :Default - mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", default_scale, 0, 0),) - - models = modellist.models - - mapping_incomplete = isnothing(modellist_status) ? - ( - Dict( - default_scale => ( - models..., - MultiScaleModel( - model=HelperCurrentTimestepModel(), - mapped_variables=[PreviousTimeStep(:next_timestep),], - ), - Status((current_timestep=1,next_timestep=1,)) - ), - )) : ( - Dict( - default_scale => ( - models..., - MultiScaleModel( - model=HelperCurrentTimestepModel(), - mapped_variables=[PreviousTimeStep(:next_timestep),], - ), - Status((modellist_status..., current_timestep=1,next_timestep=1,)) - ), - ) - ) - timestep_scale = :Default - organ = :Default - - # todo improve on this - st = last(mapping_incomplete[:Default]) - new_status, generated_models = generate_model_from_status_vector_variable(mapping_incomplete, timestep_scale, st, organ, nsteps) - - mapping = Dict(default_scale => ( - models..., generated_models..., - HelperNextTimestepModel(), - MultiScaleModel( - model=HelperCurrentTimestepModel(), - mapped_variables=[PreviousTimeStep(:next_timestep),], - ), - new_status, - ), - ) - - if isnothing(outputs) - f = [] - for i in 1:length(modellist.models) - aa = init_variables(modellist.models[i]) - bb = keys(aa) - for j in 1:length(bb) - push!(f, bb[j]) - end - #f = (f..., bb...) - end - - f = unique!(f) - all_vars = (f...,) - #all_vars = merge((keys(init_variables(object.models[i])) for i in 1:length(object.models))...) - else - all_vars = outputs - # TODO sanity check - end - - return mtg, ModelMapping(mapping), Dict(default_scale => all_vars) -end - -function modellist_to_mapping(mapping::ModelMapping{SingleScale}, modellist_status; nsteps=nothing, outputs=nothing) - modellist_to_mapping(mapping.data, modellist_status; nsteps=nsteps, outputs=outputs) -end - -function check_statuses_contain_no_remaining_vectors(mapping) - for (organ,models) in mapping - - # Special case (scales that map to a single-model don't need to be declared as a tuple for user-convenience) - if isa(models, AbstractModel) || isa(models, MultiScaleModel) || isa(models, ModelSpec) - continue - end - - for status in models - if isa(status, Status) - for symbol in keys(status) - value = getproperty(status, symbol) - if isa(value, AbstractVector) - return (organ, false) - end - end - end - end - end - return (Symbol(""), true) -end diff --git a/src/mtg/mapping/reverse_mapping.jl b/src/mtg/mapping/reverse_mapping.jl deleted file mode 100644 index aafca47a5..000000000 --- a/src/mtg/mapping/reverse_mapping.jl +++ /dev/null @@ -1,110 +0,0 @@ -""" - reverse_mapping(mapping::ModelMapping; all=true) - reverse_mapping(mapping::AbstractDict{Symbol,Tuple{Any,Vararg{Any}}}; all=true) - reverse_mapping(mapped_vars::Dict{Symbol,Dict{Symbol,Any}}) - -Get the reverse mapping of a dictionary of model mapping, *i.e.* the variables that are mapped to other scales, or in other words, -what variables are given to other scales from a given scale. -This is used for *e.g.* knowing which scales are needed to add values to others. - -# Arguments - -- `mapping::ModelMapping` (or dictionary-like mapping): the model mapping. -- `all::Bool`: Whether to get all the variables that are mapped to other scales, including the ones that are mapped as single values. - -# Returns - -A dictionary of organs (keys) with a dictionary of organs => vector of pair of variables. You can read the output as: -"for each organ (source organ), to which other organ (target organ) it is giving values for its own variables. Then for each of these source organs, which variable it is -giving to the target organ (first symbol in the pair), and to which variable it is mapping the value into the target organ (second symbol in the pair)". - -# Examples - -```jldoctest mylabel -julia> using PlantSimEngine -``` - -Import example models (can be found in the `examples` folder of the package, or in the `Examples` sub-modules): - -```jldoctest mylabel -julia> using PlantSimEngine.Examples; -``` - -```jldoctest mylabel -julia> mapping = ModelMapping( \ - :Plant => \ - MultiScaleModel( \ - model=ToyCAllocationModel(), \ - mapped_variables=[ \ - :carbon_assimilation => [:Leaf], \ - :carbon_demand => [:Leaf, :Internode], \ - :carbon_allocation => [:Leaf, :Internode] \ - ], \ - ), \ - :Internode => ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), \ - :Leaf => ( \ - MultiScaleModel( \ - model=ToyAssimModel(), \ - mapped_variables=[:soil_water_content => :Soil => :soil_water_content,], \ - ), \ - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), \ - Status(aPPFD=1300.0, TT=10.0), \ - ), \ - :Soil => ( \ - ToySoilWaterModel(), \ - ), \ - ); -``` - -Notice we provide `:Soil`, not `[:Soil]` in the mapping of the `ToyAssimModel` for the `Leaf`. This is because -we expect a single value for the `soil_water_content` to be mapped here (there is only one soil). This allows -to get the value as a singleton instead of a vector of values. - -```jldoctest mylabel -julia> rm = PlantSimEngine.reverse_mapping(mapping); - -julia> Set(keys(rm)) == Set([:Soil, :Internode, :Leaf]) -true - -julia> rm[:Soil][:Leaf][:soil_water_content] == :soil_water_content -true -``` -""" -function reverse_mapping(mapping::AbstractDict{Symbol,T}; all=true) where {T<:Any} - # Method for the reverse mapping applied directly on the mapping (not used in the code base) - mapped_vars = mapped_variables(mapping, first(hard_dependencies(mapping; verbose=false)), verbose=false) - reverse_mapping(mapped_vars, all=all) -end - -function reverse_mapping(mapped_vars::Dict{Symbol,Dict{Symbol,Any}}; all=true) - reverse_multiscale_mapping = Dict{Symbol,Dict{Symbol,Dict{Symbol,Any}}}(org => Dict{Symbol,Dict{Symbol,Any}}() for org in keys(mapped_vars)) - for (organ, vars) in mapped_vars # e.g.: organ = :Plant; vars = mapped_vars[organ] - for (var, val) in vars # e.g. var = :Rm_organs; val = vars[var] - if isa(val, MappedVar) && !isa(val, MappedVar{SelfNodeMapping}) && (all || !isa(val, MappedVar{SingleNodeMapping})) - # Note: We skip the MappedVar{SelfNodeMapping} because it is a special case where the variable is mapped to itself - # and we don't want to add it to the reverse mapping. We also skip the MappedVar{SingleNodeMapping} if `all=false` - # because we don't want to add the variables that are mapped as single values to the reverse mapping. - - mapped_orgs = mapped_organ(val) - isnothing(mapped_orgs) && continue - if mapped_orgs isa Symbol - mapped_orgs = [mapped_orgs] - elseif mapped_orgs isa AbstractString - mapped_orgs = [_normalize_scale(mapped_orgs; warn=true, context=:ModelMapping)] - end - - for mapped_o in mapped_orgs # e.g.: mapped_o = :Leaf - mapped_o == Symbol("") && continue - # if !haskey(reverse_multiscale_mapping, mapped_o) - # reverse_multiscale_mapping[mapped_o] = Dict{Symbol,Vector{MappedVar}}() - # end - if !haskey(reverse_multiscale_mapping[mapped_o], organ) - reverse_multiscale_mapping[mapped_o][organ] = Dict{Symbol,Any}(source_variable(val, mapped_o) => mapped_variable(val)) - end - push!(reverse_multiscale_mapping[mapped_o][organ], source_variable(val, mapped_o) => mapped_variable(val)) - end - end - end - end - filter!(x -> length(last(x)) > 0, reverse_multiscale_mapping) -end diff --git a/src/mtg/model_spec_inference.jl b/src/mtg/model_spec_inference.jl deleted file mode 100644 index 00dfd6ba3..000000000 --- a/src/mtg/model_spec_inference.jl +++ /dev/null @@ -1,788 +0,0 @@ -const _TIMESTEP_HINT_FIELDS = (:required, :preferred) - -""" - timestep_hint(model::AbstractModel) - timestep_hint(::Type{<:AbstractModel}) - -Optional model trait used to declare runtime compatibility constraints when -`ModelSpec.timestep` is not provided. - -Supported return values: -- `nothing` (default): no hint -- `Dates.FixedPeriod`: fixed required timestep -- `(min_period, max_period)`: required timestep range (`Dates.FixedPeriod` pair) -- `NamedTuple`: with `required` (one of the forms above) and optional `preferred` - (`:finest`, `:coarsest`, or a `Dates.FixedPeriod` within the required range). - `preferred` is informational only when runtime derives timestep from meteo. -""" -timestep_hint(model::AbstractModel) = timestep_hint(typeof(model)) -timestep_hint(::Type{<:AbstractModel}) = nothing - -""" - meteo_hint(model::AbstractModel) - meteo_hint(::Type{<:AbstractModel}) - -Optional model trait used to infer weather sampling when `ModelSpec` does not provide -`MeteoBindings(...)` and/or `MeteoWindow(...)`. - -Expected return value is a `NamedTuple` with optional fields: -- `bindings`: compatible with `MeteoBindings(...)` -- `window`: compatible with `MeteoWindow(...)` -""" -meteo_hint(model::AbstractModel) = meteo_hint(typeof(model)) -meteo_hint(::Type{<:AbstractModel}) = nothing - -struct _ResolvedTimeStepHint - fixed::Union{Nothing,Dates.FixedPeriod} - range::Union{Nothing,Tuple{Dates.FixedPeriod,Dates.FixedPeriod}} - preferred::Union{Nothing,Symbol,Dates.FixedPeriod} -end - -_seconds_from_period(p::Dates.FixedPeriod) = float(Dates.value(Dates.Millisecond(p))) * 1.0e-3 - -function _normalize_required_timestep_hint(scale::Symbol, process::Symbol, required) - if required isa Dates.FixedPeriod - _seconds_from_period(required) > 0.0 || error( - "Invalid `timestep_hint` required period for process `$(process)` at scale `$(scale)`: ", - "period must be > 0, got `$(required)`." - ) - return required, nothing - elseif required isa Tuple - length(required) == 2 || error( - "Invalid `timestep_hint` required tuple for process `$(process)` at scale `$(scale)`: ", - "expected `(min_period, max_period)`." - ) - minp, maxp = required - minp isa Dates.FixedPeriod || error( - "Invalid `timestep_hint` min period for process `$(process)` at scale `$(scale)`: ", - "expected `Dates.FixedPeriod`, got `$(typeof(minp))`." - ) - maxp isa Dates.FixedPeriod || error( - "Invalid `timestep_hint` max period for process `$(process)` at scale `$(scale)`: ", - "expected `Dates.FixedPeriod`, got `$(typeof(maxp))`." - ) - min_sec = _seconds_from_period(minp) - max_sec = _seconds_from_period(maxp) - min_sec > 0.0 || error( - "Invalid `timestep_hint` range lower bound for process `$(process)` at scale `$(scale)`: ", - "period must be > 0, got `$(minp)`." - ) - max_sec > 0.0 || error( - "Invalid `timestep_hint` range upper bound for process `$(process)` at scale `$(scale)`: ", - "period must be > 0, got `$(maxp)`." - ) - min_sec <= max_sec || error( - "Invalid `timestep_hint` range for process `$(process)` at scale `$(scale)`: ", - "lower bound `$(minp)` must be <= upper bound `$(maxp)`." - ) - return nothing, (minp, maxp) - end - - error( - "Invalid `timestep_hint` required value for process `$(process)` at scale `$(scale)`: ", - "expected `Dates.FixedPeriod` or `(Dates.FixedPeriod, Dates.FixedPeriod)`, got `$(typeof(required))`." - ) -end - -function _normalize_timestep_hint(scale::Symbol, process::Symbol, hint) - isnothing(hint) && return _ResolvedTimeStepHint(nothing, nothing, nothing) - - if hint isa Dates.FixedPeriod || hint isa Tuple - fixed, range = _normalize_required_timestep_hint(scale, process, hint) - return _ResolvedTimeStepHint(fixed, range, nothing) - elseif hint isa NamedTuple - extra = setdiff(collect(keys(hint)), collect(_TIMESTEP_HINT_FIELDS)) - isempty(extra) || error( - "Invalid `timestep_hint` for process `$(process)` at scale `$(scale)`: ", - "unsupported fields $(extra)." - ) - haskey(hint, :required) || error( - "Invalid `timestep_hint` for process `$(process)` at scale `$(scale)`: ", - "field `required` is mandatory when using NamedTuple form." - ) - fixed, range = _normalize_required_timestep_hint(scale, process, hint.required) - preferred = haskey(hint, :preferred) ? hint.preferred : nothing - if !isnothing(preferred) - if preferred isa Symbol - preferred in (:finest, :coarsest) || error( - "Invalid `timestep_hint.preferred` for process `$(process)` at scale `$(scale)`: ", - "supported symbols are `:finest` and `:coarsest`." - ) - elseif preferred isa Dates.FixedPeriod - _seconds_from_period(preferred) > 0.0 || error( - "Invalid `timestep_hint.preferred` for process `$(process)` at scale `$(scale)`: ", - "period must be > 0, got `$(preferred)`." - ) - if !isnothing(range) - lo, hi = range - preferred_sec = _seconds_from_period(preferred) - lo_sec = _seconds_from_period(lo) - hi_sec = _seconds_from_period(hi) - lo_sec <= preferred_sec <= hi_sec || error( - "Invalid `timestep_hint.preferred=$(preferred)` for process `$(process)` at scale `$(scale)`: ", - "preferred period must be inside required range `($(lo), $(hi))`." - ) - elseif !isnothing(fixed) - _seconds_from_period(preferred) == _seconds_from_period(fixed) || error( - "Invalid `timestep_hint.preferred=$(preferred)` for process `$(process)` at scale `$(scale)`: ", - "when `required` is fixed (`$(fixed)`), `preferred` must match it." - ) - end - else - error( - "Invalid `timestep_hint.preferred` for process `$(process)` at scale `$(scale)`: ", - "expected `:finest`, `:coarsest`, or `Dates.FixedPeriod`, got `$(typeof(preferred))`." - ) - end - end - return _ResolvedTimeStepHint(fixed, range, preferred) - end - - error( - "Invalid `timestep_hint` for process `$(process)` at scale `$(scale)`: ", - "expected `nothing`, `Dates.FixedPeriod`, `(min,max)` tuple, or NamedTuple, got `$(typeof(hint))`." - ) -end - -function _infer_timestep_hints!(model_specs) - # `timestep_hint` is parsed/validated here but does not assign runtime dt. - # Runtime derives dt from: - # 1) explicit `ModelSpec.timestep` - # 2) model `timespec(model)` when non-default - # 3) meteo base step (default fallback) - for (scale, specs_at_scale) in pairs(model_specs) - for (process, spec) in pairs(specs_at_scale) - isnothing(timestep(spec)) || continue - _normalize_timestep_hint(scale, process, timestep_hint(model_(spec))) - end - end - - return nothing -end - -function _format_candidate_list(candidates) - isempty(candidates) && return "(none)" - return join(["$(c.scale)/$(c.process)" for c in candidates], ", ") -end - -function _is_stream_only_output(spec::ModelSpec, var::Symbol) - routing = output_routing(spec) - mode = var in keys(routing) ? routing[var] : :canonical - return mode == :stream_only -end - -function _scale_reachable(scale_reachability, consumer_scale::Symbol, source_scale::Symbol) - isnothing(scale_reachability) && return true - # If one of the scales is not present in the initial MTG, reachability is - # unknown at init time: keep candidate permissively. - haskey(scale_reachability, consumer_scale) || return true - haskey(scale_reachability, source_scale) || return true - allowed = scale_reachability[consumer_scale] - return source_scale in allowed -end - -function _scale_reachability_from_mtg(mtg) - scale_reachability = Dict{Symbol,Set{Symbol}}() - - MultiScaleTreeGraph.traverse!(mtg) do node - scale = symbol(node) - push!(get!(scale_reachability, scale, Set{Symbol}()), scale) - - ancestor = parent(node) - while !isnothing(ancestor) - ancestor_scale = symbol(ancestor) - # Scales are reachable only when they appear on the same MTG lineage - # (ancestor/descendant relation). Sibling-only scales are excluded. - push!(get!(scale_reachability, scale, Set{Symbol}()), ancestor_scale) - push!(get!(scale_reachability, ancestor_scale, Set{Symbol}()), scale) - ancestor = parent(ancestor) - end - end - - return scale_reachability -end - -function _effective_timestep_spec(spec::ModelSpec) - ts = timestep(spec) - return isnothing(ts) ? timespec(model_(spec)) : ts -end - -function _timestep_resolution_source(spec::ModelSpec) - !isnothing(timestep(spec)) && return :modelspec - return _same_timestep_signature( - _timestep_signature(timespec(model_(spec))), - _timestep_signature(ClockSpec(1.0, 0.0)) - ) ? :meteo_base_step : :model_timespec -end - -function _timestep_signature(ts) - if ts isa ClockSpec - return (:clock, float(ts.dt), float(ts.phase)) - elseif ts isa Real - return (:step, float(ts), 0.0) - elseif ts isa Dates.FixedPeriod - return (:period, _seconds_from_period(ts), 0.0) - end - return nothing -end - -function _same_timestep_signature(sig_a, sig_b) - isnothing(sig_a) && return false - isnothing(sig_b) && return false - - if sig_a[1] == :period || sig_b[1] == :period - return sig_a[1] == :period && - sig_b[1] == :period && - isapprox(sig_a[2], sig_b[2]; atol=1.0e-9, rtol=0.0) - end - - phase_a = sig_a[1] == :step ? 0.0 : sig_a[3] - phase_b = sig_b[1] == :step ? 0.0 : sig_b[3] - return isapprox(sig_a[2], sig_b[2]; atol=1.0e-9, rtol=0.0) && - isapprox(phase_a, phase_b; atol=1.0e-9, rtol=0.0) -end - -function _hard_dep_same_rate_as_parent(model_specs, parent_scale::Symbol, parent_process::Symbol, child_scale::Symbol, child_process::Symbol) - parent_scale == child_scale || return false - parent_specs = get(model_specs, parent_scale, nothing) - isnothing(parent_specs) && return false - parent_spec = get(parent_specs, parent_process, nothing) - child_spec = get(parent_specs, child_process, nothing) - isnothing(parent_spec) && return false - isnothing(child_spec) && return false - - parent_sig = _timestep_signature(_effective_timestep_spec(parent_spec)) - child_sig = _timestep_signature(_effective_timestep_spec(child_spec)) - return _same_timestep_signature(parent_sig, child_sig) -end - -function _collect_same_rate_hard_dependency_children!( - ignored_processes_by_scale::Dict{Symbol,Set{Symbol}}, - model_specs, - parent_scale::Symbol, - parent_process::Symbol, - child::HardDependencyNode -) - if _hard_dep_same_rate_as_parent(model_specs, parent_scale, parent_process, child.scale, child.process) - push!(get!(ignored_processes_by_scale, child.scale, Set{Symbol}()), child.process) - end - - for nested in child.children - _collect_same_rate_hard_dependency_children!( - ignored_processes_by_scale, - model_specs, - child.scale, - child.process, - nested - ) - end - - return nothing -end - -function _soft_nodes_for_hard_dependency_analysis(dep_graph::DependencyGraph{Dict{Symbol,Any}}) - nodes = SoftDependencyNode[] - for (_, roots_at_scale) in pairs(dep_graph.roots) - haskey(roots_at_scale, :soft_dep_graph) || continue - append!(nodes, values(roots_at_scale[:soft_dep_graph])) - end - return nodes -end - -_soft_nodes_for_hard_dependency_analysis(dep_graph::DependencyGraph) = traverse_dependency_graph(dep_graph, false) - -function _same_rate_hard_dependency_children(model_specs, dep_graph::DependencyGraph) - ignored_processes_by_scale = Dict{Symbol,Set{Symbol}}() - - for soft_node in _soft_nodes_for_hard_dependency_analysis(dep_graph) - for child in soft_node.hard_dependency - _collect_same_rate_hard_dependency_children!( - ignored_processes_by_scale, - model_specs, - soft_node.scale, - soft_node.process, - child - ) - end - end - - return ignored_processes_by_scale -end - -function _active_processes_for_inference(model_specs, ignored_processes_by_scale::Dict{Symbol,Set{Symbol}}) - active = Dict{Symbol,Set{Symbol}}() - for (scale, specs_at_scale) in pairs(model_specs) - procs = Set{Symbol}(keys(specs_at_scale)) - ignored = get(ignored_processes_by_scale, scale, Set{Symbol}()) - for process in ignored - delete!(procs, process) - end - active[scale] = procs - end - return active -end - -function _input_candidates_for_var( - model_specs, - consumer_scale::Symbol, - consumer_process::Symbol, - input_var::Symbol; - scale_reachability=nothing, - active_processes_by_scale=nothing -) - same_scale = NamedTuple[] - cross_scale = NamedTuple[] - - for (scale, specs_at_scale) in pairs(model_specs) - for (process, spec) in pairs(specs_at_scale) - if !isnothing(active_processes_by_scale) - active = get(active_processes_by_scale, scale, Set{Symbol}()) - process in active || continue - end - scale == consumer_scale && process == consumer_process && continue - input_var in keys(outputs_(model_(spec))) || continue - _is_stream_only_output(spec, input_var) && continue - if scale != consumer_scale && !_scale_reachable(scale_reachability, consumer_scale, scale) - continue - end - c = (scale=scale, process=process, var=input_var) - if scale == consumer_scale - push!(same_scale, c) - else - push!(cross_scale, c) - end - end - end - - return same_scale, cross_scale -end - -function _default_policy_for_inferred_binding(model_specs, source_scale::Symbol, source_process::Symbol, source_var::Symbol) - source_spec = model_specs[source_scale][source_process] - source_model = model_(source_spec) - source_output_policy = output_policy(source_model) - source_var in keys(source_output_policy) || return HoldLast() - return _as_schedule_policy( - source_output_policy[source_var]; - context="output_policy for inferred binding from `$(source_scale)/$(source_process).$(source_var)`" - ) -end - -function _mapped_source_scales_for_input(spec::ModelSpec, input_var::Symbol) - mapped = mapped_variables_(spec) - isempty(mapped) && return Set{Symbol}() - - scales = Set{Symbol}() - for mv in mapped - mapped_input = first(mv) - mapped_input = mapped_input isa PreviousTimeStep ? mapped_input.variable : mapped_input - mapped_input == input_var || continue - - rhs = last(mv) - if rhs isa Pair{Symbol,Symbol} - src_scale = first(rhs) - src_scale == Symbol("") || push!(scales, src_scale) - elseif rhs isa AbstractVector - for item in rhs - item isa Pair{Symbol,Symbol} || continue - src_scale = first(item) - src_scale == Symbol("") || push!(scales, src_scale) - end - end - end - - return scales -end - -function _input_has_multiscale_mapping(spec::ModelSpec, input_var::Symbol) - mapped = mapped_variables_(spec) - isempty(mapped) && return false - - for mv in mapped - mapped_input = first(mv) - mapped_input = mapped_input isa PreviousTimeStep ? mapped_input.variable : mapped_input - mapped_input == input_var && return true - end - - return false -end - -function _mapped_sources_for_input(spec::ModelSpec, input_var::Symbol) - mapped = mapped_variables_(spec) - isempty(mapped) && return Pair{Symbol,Symbol}[] - - sources = Pair{Symbol,Symbol}[] - for mv in mapped - mapped_input = first(mv) - mapped_input = mapped_input isa PreviousTimeStep ? mapped_input.variable : mapped_input - mapped_input == input_var || continue - - rhs = last(mv) - if rhs isa Pair{Symbol,Symbol} - push!(sources, rhs) - elseif rhs isa AbstractVector - for item in rhs - item isa Pair{Symbol,Symbol} || continue - push!(sources, item) - end - end - end - - return sources -end - -function _infer_binding_from_multiscale_mapping( - model_specs, - scale::Symbol, - process::Symbol, - spec::ModelSpec, - input_var::Symbol; - active_processes_by_scale=nothing -) - has_mapping = _input_has_multiscale_mapping(spec, input_var) - has_mapping || return nothing - - mapped_sources = _mapped_sources_for_input(spec, input_var) - # Mapping exists but does not point to another scale (self/same-scale aliasing): - # avoid generic same-name inference in that case. - filtered_sources = filter(s -> first(s) != Symbol(""), mapped_sources) - isempty(filtered_sources) && return :skip - - # Multi-source mapping (e.g. vectors from several scales) cannot be represented - # as one `InputBindings` entry; keep binding unresolved and skip generic inference. - length(filtered_sources) == 1 || return :skip - - src = only(filtered_sources) - src_scale = first(src) - src_var = last(src) - haskey(model_specs, src_scale) || return :skip - - procs = Symbol[] - for (src_process, src_spec) in pairs(model_specs[src_scale]) - if !isnothing(active_processes_by_scale) - active = get(active_processes_by_scale, src_scale, Set{Symbol}()) - src_process in active || continue - end - src_var in keys(outputs_(model_(src_spec))) || continue - _is_stream_only_output(src_spec, src_var) && continue - push!(procs, src_process) - end - - length(procs) == 1 || return :skip - src_process = only(procs) - policy = _default_policy_for_inferred_binding(model_specs, src_scale, src_process, src_var) - return (process=src_process, var=src_var, scale=src_scale, policy=policy) -end - -function _infer_input_binding_for_var( - model_specs, - scale::Symbol, - process::Symbol, - input_var::Symbol; - scale_reachability=nothing, - active_processes_by_scale=nothing -) - same_scale, cross_scale = _input_candidates_for_var( - model_specs, - scale, - process, - input_var; - scale_reachability=scale_reachability, - active_processes_by_scale=active_processes_by_scale - ) - - if length(same_scale) == 1 - c = only(same_scale) - policy = _default_policy_for_inferred_binding(model_specs, c.scale, c.process, c.var) - return (process=c.process, var=c.var, scale=c.scale, policy=policy) - elseif length(same_scale) > 1 - error( - "Ambiguous inferred producer for input `$(input_var)` in process `$(process)` at scale `$(scale)`. ", - "Multiple same-scale candidates were found: $(_format_candidate_list(same_scale)). ", - "Please provide explicit `InputBindings(...)`." - ) - end - - if length(cross_scale) == 1 - c = only(cross_scale) - policy = _default_policy_for_inferred_binding(model_specs, c.scale, c.process, c.var) - return (process=c.process, var=c.var, scale=c.scale, policy=policy) - elseif length(cross_scale) > 1 - by_process = Dict{Symbol,Vector{NamedTuple}}() - for c in cross_scale - push!(get!(by_process, c.process, NamedTuple[]), c) - end - - if length(by_process) == 1 - proc = only(keys(by_process)) - scales = unique(c.scale for c in by_process[proc]) - if length(scales) == 1 - src_scale = only(scales) - policy = _default_policy_for_inferred_binding(model_specs, src_scale, proc, input_var) - return (process=proc, var=input_var, scale=src_scale, policy=policy) - end - - # When multiscale mapping already declares a source scale for this - # input, use it to disambiguate instead of forcing explicit bindings. - consumer_spec = model_specs[scale][process] - mapped_scales = _mapped_source_scales_for_input(consumer_spec, input_var) - candidate_scales = Set(scales) - hinted_scales = intersect(mapped_scales, candidate_scales) - if length(hinted_scales) == 1 - src_scale = only(hinted_scales) - policy = _default_policy_for_inferred_binding(model_specs, src_scale, proc, input_var) - return (process=proc, var=input_var, scale=src_scale, policy=policy) - end - - error( - "Ambiguous inferred producer for input `$(input_var)` in process `$(process)` at scale `$(scale)`. ", - "Process `$(proc)` publishes this variable at multiple reachable scales: $(join(scales, ", ")). ", - "Please provide explicit `InputBindings(...)` with `scale`, ", - "or add a `MultiScaleModel(...)` mapping so the source scale is unambiguous." - ) - end - - error( - "Ambiguous inferred producer for input `$(input_var)` in process `$(process)` at scale `$(scale)`. ", - "Multiple cross-scale candidates were found: $(_format_candidate_list(cross_scale)). ", - "Please provide explicit `InputBindings(...)`." - ) - end - - # No producer found. Keep input unresolved so user-provided initialization/forced - # values can still drive the model. - return nothing -end - -function _infer_input_bindings!(model_specs; scale_reachability=nothing, active_processes_by_scale=nothing) - for (scale, specs_at_scale) in pairs(model_specs) - # When a scale is absent from the initial MTG, input producer inference at - # init time is unreliable (dynamic growth may introduce it later). Keep - # bindings unresolved and let runtime resolve from actual dependencies. - if !isnothing(scale_reachability) && !haskey(scale_reachability, scale) - continue - end - for (process, spec) in pairs(specs_at_scale) - if !isnothing(active_processes_by_scale) - active = get(active_processes_by_scale, scale, Set{Symbol}()) - process in active || continue - end - current_bindings = input_bindings(spec) - current_bindings isa NamedTuple || continue - - inferred = Pair{Symbol,Any}[] - model_inputs = keys(inputs_(model_(spec))) - - for input_var in model_inputs - input_var in keys(current_bindings) && continue - mapped_binding = _infer_binding_from_multiscale_mapping( - model_specs, - scale, - process, - spec, - input_var; - active_processes_by_scale=active_processes_by_scale - ) - if mapped_binding === :skip - continue - elseif !isnothing(mapped_binding) - push!(inferred, input_var => mapped_binding) - continue - end - inferred_binding = _infer_input_binding_for_var( - model_specs, - scale, - process, - input_var; - scale_reachability=scale_reachability, - active_processes_by_scale=active_processes_by_scale - ) - isnothing(inferred_binding) && continue - push!(inferred, input_var => inferred_binding) - end - - isempty(inferred) && continue - merged = (; pairs(current_bindings)..., inferred...) - specs_at_scale[process] = ModelSpec(spec; input_bindings=merged) - end - end - - return nothing -end - -function _normalize_meteo_hint(scale::Symbol, process::Symbol, hint) - isnothing(hint) && return (bindings=nothing, window=nothing) - - hint isa NamedTuple || error( - "Invalid `meteo_hint` for process `$(process)` at scale `$(scale)`: ", - "expected NamedTuple with optional fields `bindings` and `window`, got `$(typeof(hint))`." - ) - - allowed = (:bindings, :window) - extra = setdiff(collect(keys(hint)), collect(allowed)) - isempty(extra) || error( - "Invalid `meteo_hint` for process `$(process)` at scale `$(scale)`: ", - "unsupported fields $(extra)." - ) - - bindings = haskey(hint, :bindings) ? _normalize_meteo_bindings(hint.bindings) : nothing - window = haskey(hint, :window) ? _normalize_meteo_window(hint.window) : nothing - return (bindings=bindings, window=window) -end - -function _infer_meteo_hints!(model_specs) - for (scale, specs_at_scale) in pairs(model_specs) - for (process, spec) in pairs(specs_at_scale) - hint = _normalize_meteo_hint(scale, process, meteo_hint(model_(spec))) - - current_bindings = meteo_bindings(spec) - has_explicit_bindings = !(current_bindings isa NamedTuple && isempty(keys(current_bindings))) - new_bindings = has_explicit_bindings ? current_bindings : (isnothing(hint.bindings) ? current_bindings : hint.bindings) - - current_window = meteo_window(spec) - new_window = isnothing(current_window) ? (isnothing(hint.window) ? current_window : hint.window) : current_window - - if (new_bindings !== current_bindings) || (new_window !== current_window) - specs_at_scale[process] = ModelSpec(spec; meteo_bindings=new_bindings, meteo_window=new_window) - end - end - end - - return nothing -end - -""" - infer_model_specs_configuration!(model_specs) - -Fill missing `ModelSpec` fields from inference: -- auto input bindings from unique same-name producers - (including default policy from producer `output_policy`) -- model-level hint traits (`timestep_hint`, `meteo_hint`) -Explicit `ModelSpec` user values always take precedence over inferred values. -""" -function infer_model_specs_configuration!(model_specs; scale_reachability=nothing, active_processes_by_scale=nothing) - _infer_input_bindings!( - model_specs; - scale_reachability=scale_reachability, - active_processes_by_scale=active_processes_by_scale - ) - _infer_timestep_hints!(model_specs) - _infer_meteo_hints!(model_specs) - return model_specs -end - -""" - resolved_model_specs(mapping; infer=true, validate=true) - resolved_model_specs(sim::GraphSimulation) - -Return process-indexed `ModelSpec` dictionaries as used by runtime: -`Dict{Symbol, Dict{Symbol, ModelSpec}}`. - -For a mapping, this parses model declarations and optionally applies inference -(`timestep_hint`, `meteo_hint`) and validation. -For a `GraphSimulation`, this returns the already resolved model specs used by the simulation. -""" -function resolved_model_specs(mapping::AbstractDict; infer::Bool=true, validate::Bool=true) - model_specs = Dict{Symbol,Dict{Symbol,ModelSpec}}() - for (scale, declarations) in pairs(mapping) - scale_sym = if scale isa Symbol - scale - elseif scale isa AbstractString - _normalize_scale(scale; warn=true, context=:ModelSpec) - else - error("Scale keys in `resolved_model_specs(mapping)` must be `Symbol` (preferred) or `String`, got `$(typeof(scale))`.") - end - model_specs[scale_sym] = parse_model_specs(declarations) - end - - infer && infer_model_specs_configuration!(model_specs) - validate && validate_model_specs_configuration(model_specs) - return model_specs -end - -resolved_model_specs(sim::GraphSimulation; infer::Bool=true, validate::Bool=true) = get_model_specs(sim) - -function _stringify_compact(x; maxlen::Int=120) - s = sprint(show, x) - return ncodeunits(s) <= maxlen ? s : string(first(s, maxlen - 3), "...") -end - -function _model_specs_rows(model_specs) - rows = NamedTuple[] - for scale in sort!(collect(keys(model_specs))) - specs_at_scale = model_specs[scale] - for process in sort!(collect(keys(specs_at_scale)); by=string) - spec = specs_at_scale[process] - resolution = _timestep_resolution_source(spec) - push!(rows, ( - scale=scale, - process=process, - model=typeof(model_(spec)), - timestep=timestep(spec), - timespec_default=timespec(model_(spec)), - timestep_resolution=resolution, - input_bindings=input_bindings(spec), - meteo_bindings=meteo_bindings(spec), - meteo_window=meteo_window(spec), - )) - end - end - return rows -end - -""" - explain_model_specs(target; io=stdout, infer=true, validate=true) - -Print a compact per-model summary of resolved runtime configuration and return it -as a vector of named tuples. - -Summary fields: -- `scale` -- `process` -- `model` -- `timestep` -- `input_bindings` -- `meteo_bindings` -- `meteo_window` -""" -function explain_model_specs(target; io::IO=stdout, infer::Bool=true, validate::Bool=true) - specs = target isa GraphSimulation ? resolved_model_specs(target) : resolved_model_specs(target; infer=infer, validate=validate) - rows = _model_specs_rows(specs) - - println(io, "Resolved model specs:") - if isempty(rows) - println(io, " (no model specs)") - return rows - end - - for row in rows - timestep_desc = if row.timestep_resolution == :modelspec - string(_stringify_compact(row.timestep), " [explicit ModelSpec]") - elseif row.timestep_resolution == :model_timespec - string(_stringify_compact(row.timespec_default), " [model timespec]") - else - "(meteo base step at runtime)" - end - input_bindings_desc = (row.input_bindings isa NamedTuple && isempty(keys(row.input_bindings))) ? "(none)" : _stringify_compact(row.input_bindings) - meteo_bindings_desc = (row.meteo_bindings isa NamedTuple && isempty(keys(row.meteo_bindings))) ? "(none)" : _stringify_compact(row.meteo_bindings) - meteo_window_desc = isnothing(row.meteo_window) ? "(default rolling)" : _stringify_compact(row.meteo_window) - println( - io, - " - ", - row.scale, - "/", - row.process, - " [", - row.model, - "]: timestep=", - timestep_desc, - ", input_bindings=", - input_bindings_desc, - ", meteo_bindings=", - meteo_bindings_desc, - ", meteo_window=", - meteo_window_desc - ) - end - return rows -end diff --git a/src/mtg/model_spec_validation.jl b/src/mtg/model_spec_validation.jl deleted file mode 100644 index e45cbfd6f..000000000 --- a/src/mtg/model_spec_validation.jl +++ /dev/null @@ -1,440 +0,0 @@ -const _INPUT_BINDING_FIELDS = (:process, :var, :scale, :policy) -const _MODEL_SCOPE_SELECTORS = (:global, :plant, :scene, :self) -const _METEO_BINDING_FIELDS = (:source, :reducer) -const _CALENDAR_PERIODS = (:day, :week, :month) -const _CALENDAR_ANCHORS = (:current_period, :previous_complete_period) -const _CALENDAR_COMPLETENESS = (:allow_partial, :strict) - -function _validate_window_reducer(scale::Symbol, process::Symbol, input_var::Symbol, policy_name::Symbol, reducer) - vals_probe = [1.0, 2.0] - durations_probe = [1.0, 1.0] - - if reducer isa DataType - reducer <: PlantMeteo.AbstractTimeReducer || error( - "Invalid reducer type `$(reducer)` for policy `$(policy_name)` on input `$(input_var)` ", - "in process `$(process)` at scale `$(scale)`. ", - "Expected a PlantMeteo reducer type/instance or a callable." - ) - rr = try - reducer() - catch - error( - "Reducer type `$(reducer)` for policy `$(policy_name)` on input `$(input_var)` ", - "in process `$(process)` at scale `$(scale)` cannot be instantiated without arguments." - ) - end - (applicable(rr, vals_probe) || applicable(rr, vals_probe, durations_probe)) || error( - "Reducer type `$(reducer)` for policy `$(policy_name)` on input `$(input_var)` in process `$(process)` at scale `$(scale)` ", - "must be callable on `(values)` or `(values, durations)`." - ) - return nothing - elseif reducer isa PlantMeteo.AbstractTimeReducer - (applicable(reducer, vals_probe) || applicable(reducer, vals_probe, durations_probe)) || error( - "Reducer `$(typeof(reducer))` for policy `$(policy_name)` on input `$(input_var)` in process `$(process)` at scale `$(scale)` ", - "must be callable on `(values)` or `(values, durations)`." - ) - return nothing - elseif reducer isa Function - (applicable(reducer, vals_probe) || applicable(reducer, vals_probe, durations_probe)) || error( - "Reducer for policy `$(policy_name)` on input `$(input_var)` in process `$(process)` at scale `$(scale)` ", - "must be callable on `(values)` or `(values, durations)`." - ) - return nothing - end - - error( - "Invalid reducer value `$(reducer)` (type `$(typeof(reducer))`) for policy `$(policy_name)` ", - "on input `$(input_var)` in process `$(process)` at scale `$(scale)`. ", - "Expected a PlantMeteo reducer type/instance or a callable." - ) -end - -function _validate_policy_instance(scale::Symbol, process::Symbol, input_var::Symbol, policy::SchedulePolicy) - if policy isa HoldLast - return nothing - elseif policy isa Interpolate - policy.mode in _INTERPOLATE_MODES || error( - "Invalid interpolation mode `$(policy.mode)` for input `$(input_var)` in process `$(process)` at scale `$(scale)`. ", - "Supported modes are $(_INTERPOLATE_MODES)." - ) - policy.extrapolation in _INTERPOLATE_MODES || error( - "Invalid interpolation extrapolation `$(policy.extrapolation)` for input `$(input_var)` in process `$(process)` at scale `$(scale)`. ", - "Supported values are $(_INTERPOLATE_MODES)." - ) - return nothing - elseif policy isa Integrate - _validate_window_reducer(scale, process, input_var, :Integrate, policy.reducer) - return nothing - elseif policy isa Aggregate - _validate_window_reducer(scale, process, input_var, :Aggregate, policy.reducer) - return nothing - end - - return nothing -end - -function _validate_timestep_spec(scale::Symbol, process::Symbol, spec::ModelSpec) - ts = timestep(spec) - isnothing(ts) && return nothing - - if ts isa ClockSpec - float(ts.dt) > 0 || error( - "Invalid timestep for process `$(process)` at scale `$(scale)`: ", - "`ClockSpec.dt` must be > 0, got $(ts.dt)." - ) - return nothing - end - - if ts isa Real - float(ts) > 0 || error( - "Invalid timestep for process `$(process)` at scale `$(scale)`: ", - "numeric timestep must be > 0, got $(ts)." - ) - return nothing - end - - if ts isa Dates.Period - ts isa Dates.FixedPeriod || error( - "Invalid timestep for process `$(process)` at scale `$(scale)`: ", - "non-fixed periods are not supported (`$(typeof(ts))`). ", - "Use fixed periods such as `Second`, `Minute`, `Hour` or `Day`." - ) - Dates.value(Dates.Second(ts)) > 0 || error( - "Invalid timestep for process `$(process)` at scale `$(scale)`: ", - "period must be > 0, got $(ts)." - ) - return nothing - end - - error( - "Invalid timestep for process `$(process)` at scale `$(scale)`: ", - "expected `Real`, `ClockSpec` or `Dates.Period`, got `$(typeof(ts))`." - ) -end - -function _validate_scope_spec(scale::Symbol, process::Symbol, spec::ModelSpec) - selector = model_scope(spec) - if selector isa ScopeId - return nothing - elseif selector isa Symbol - selector in _MODEL_SCOPE_SELECTORS || error( - "Invalid scope selector `$(selector)` for process `$(process)` at scale `$(scale)`. ", - "Supported selectors are $(_MODEL_SCOPE_SELECTORS), `ScopeId`, or a callable." - ) - return nothing - elseif selector isa AbstractString - Symbol(selector) in _MODEL_SCOPE_SELECTORS || error( - "Invalid scope selector `$(selector)` for process `$(process)` at scale `$(scale)`. ", - "Supported selectors are $(_MODEL_SCOPE_SELECTORS), `ScopeId`, or a callable." - ) - return nothing - elseif selector isa Function - return nothing - end - - error( - "Invalid scope selector for process `$(process)` at scale `$(scale)`: ", - "expected `Symbol`, `String`, `ScopeId`, or callable, got `$(typeof(selector))`." - ) -end - -function _validate_binding_policy(scale::Symbol, process::Symbol, input_var::Symbol, policy) - if policy isa DataType - policy <: SchedulePolicy || error( - "Invalid policy for input `$(input_var)` in process `$(process)` at scale `$(scale)`: ", - "expected a `SchedulePolicy` type or instance, got `$(policy)`." - ) - p = try - policy() - catch - error( - "Invalid policy type `$(policy)` for input `$(input_var)` in process `$(process)` at scale `$(scale)`: ", - "this policy type cannot be instantiated without arguments. Provide a policy instance instead." - ) - end - _validate_policy_instance(scale, process, input_var, p) - return nothing - end - - policy isa SchedulePolicy || error( - "Invalid policy for input `$(input_var)` in process `$(process)` at scale `$(scale)`: ", - "expected a `SchedulePolicy` type or instance, got `$(typeof(policy))`." - ) - _validate_policy_instance(scale, process, input_var, policy) - - return nothing -end - -function _validate_binding_target( - scale::Symbol, - process::Symbol, - input_var::Symbol, - source_process::Symbol, - source_scale, - model_specs, - known_processes::Set{Symbol} -) - source_process in known_processes || error( - "Unknown source process `$(source_process)` for input `$(input_var)` in process `$(process)` at scale `$(scale)`." - ) - - isnothing(source_scale) && return nothing - src_scale = source_scale isa AbstractString ? - _normalize_scale(source_scale; warn=true, context=:ModelSpec) : - source_scale - haskey(model_specs, src_scale) || error( - "Unknown source scale `$(src_scale)` for input `$(input_var)` in process `$(process)` at scale `$(scale)`." - ) - source_process in keys(model_specs[src_scale]) || error( - "Source process `$(source_process)` for input `$(input_var)` in process `$(process)` ", - "is not declared at scale `$(src_scale)`." - ) - return nothing -end - -function _validate_input_binding( - scale::Symbol, - process::Symbol, - input_var::Symbol, - binding, - model_specs, - known_processes::Set{Symbol} -) - source_process = nothing - source_scale = nothing - policy = HoldLast() - - if binding isa Symbol - source_process = binding - elseif binding isa Pair{Symbol,Symbol} - source_process = first(binding) - elseif binding isa NamedTuple - extra = setdiff(collect(keys(binding)), collect(_INPUT_BINDING_FIELDS)) - isempty(extra) || error( - "Invalid input binding for input `$(input_var)` in process `$(process)` at scale `$(scale)`: ", - "unsupported fields $(extra)." - ) - haskey(binding, :process) || error( - "Invalid input binding for input `$(input_var)` in process `$(process)` at scale `$(scale)`: ", - "field `process` is required." - ) - binding.process isa Symbol || error( - "Invalid input binding for input `$(input_var)` in process `$(process)` at scale `$(scale)`: ", - "`process` must be a Symbol, got `$(typeof(binding.process))`." - ) - source_process = binding.process - - if haskey(binding, :var) - isnothing(binding.var) || binding.var isa Symbol || error( - "Invalid input binding for input `$(input_var)` in process `$(process)` at scale `$(scale)`: ", - "`var` must be a Symbol or `nothing`, got `$(typeof(binding.var))`." - ) - end - - if haskey(binding, :scale) - isnothing(binding.scale) || binding.scale isa Symbol || binding.scale isa AbstractString || error( - "Invalid input binding for input `$(input_var)` in process `$(process)` at scale `$(scale)`: ", - "`scale` must be a Symbol, String or `nothing`, got `$(typeof(binding.scale))`." - ) - source_scale = binding.scale - end - - policy = haskey(binding, :policy) ? binding.policy : HoldLast() - else - error( - "Invalid input binding for input `$(input_var)` in process `$(process)` at scale `$(scale)`: ", - "unsupported binding type `$(typeof(binding))`." - ) - end - - _validate_binding_policy(scale, process, input_var, policy) - _validate_binding_target(scale, process, input_var, source_process, source_scale, model_specs, known_processes) - return nothing -end - -function _validate_input_bindings_for_spec( - scale::Symbol, - process::Symbol, - spec::ModelSpec, - model_specs, - known_processes::Set{Symbol} -) - bindings = input_bindings(spec) - bindings isa NamedTuple || error( - "InputBindings for process `$(process)` at scale `$(scale)` must be a NamedTuple, got `$(typeof(bindings))`." - ) - - model_inputs = Set(keys(inputs_(model_(spec)))) - for (input_var, binding) in pairs(bindings) - input_var isa Symbol || error( - "InputBindings key for process `$(process)` at scale `$(scale)` must be a Symbol, got `$(typeof(input_var))`." - ) - input_var in model_inputs || error( - "InputBindings for process `$(process)` at scale `$(scale)` declares binding for input `$(input_var)`, ", - "but model inputs are $(collect(model_inputs))." - ) - _validate_input_binding(scale, process, input_var, binding, model_specs, known_processes) - end - return nothing -end - -function _validate_output_routing_for_spec(scale::Symbol, process::Symbol, spec::ModelSpec) - routing = output_routing(spec) - routing isa NamedTuple || error( - "OutputRouting for process `$(process)` at scale `$(scale)` must be a NamedTuple, got `$(typeof(routing))`." - ) - - model_outputs = Set(keys(outputs_(model_(spec)))) - for (out_var, mode) in pairs(routing) - out_var isa Symbol || error( - "OutputRouting key for process `$(process)` at scale `$(scale)` must be a Symbol, got `$(typeof(out_var))`." - ) - out_var in model_outputs || error( - "OutputRouting for process `$(process)` at scale `$(scale)` declares routing for output `$(out_var)`, ", - "but model outputs are $(collect(model_outputs))." - ) - - mode_sym = mode isa Symbol ? mode : (mode isa AbstractString ? Symbol(mode) : nothing) - isnothing(mode_sym) && error( - "OutputRouting mode for output `$(out_var)` in process `$(process)` at scale `$(scale)` ", - "must be `:canonical` or `:stream_only`." - ) - mode_sym in (:canonical, :stream_only) || error( - "OutputRouting mode `$(mode_sym)` for output `$(out_var)` in process `$(process)` at scale `$(scale)` ", - "is invalid. Allowed values: `:canonical`, `:stream_only`." - ) - end - - return nothing -end - -function _validate_meteo_binding(scale::Symbol, process::Symbol, target_var::Symbol, binding) - if binding isa Function || binding isa PlantMeteo.AbstractTimeReducer - return nothing - elseif binding isa DataType - binding <: PlantMeteo.AbstractTimeReducer || error( - "Invalid MeteoBindings reducer type for variable `$(target_var)` in process `$(process)` at scale `$(scale)`: ", - "expected a subtype of `PlantMeteo.AbstractTimeReducer`." - ) - try - binding() - catch - error( - "Invalid MeteoBindings reducer type for variable `$(target_var)` in process `$(process)` at scale `$(scale)`: ", - "type `$(binding)` cannot be instantiated without arguments." - ) - end - return nothing - elseif binding isa NamedTuple - extra = setdiff(collect(keys(binding)), collect(_METEO_BINDING_FIELDS)) - isempty(extra) || error( - "Invalid MeteoBindings for variable `$(target_var)` in process `$(process)` at scale `$(scale)`: ", - "unsupported fields $(extra)." - ) - - if haskey(binding, :source) - binding.source isa Symbol || binding.source isa AbstractString || error( - "Invalid MeteoBindings source for variable `$(target_var)` in process `$(process)` at scale `$(scale)`: ", - "`source` must be a Symbol or String." - ) - end - if haskey(binding, :reducer) - reducer = binding.reducer - if reducer isa DataType - reducer <: PlantMeteo.AbstractTimeReducer || error( - "Invalid MeteoBindings reducer for variable `$(target_var)` in process `$(process)` at scale `$(scale)`: ", - "`reducer` type must subtype `PlantMeteo.AbstractTimeReducer`." - ) - try - reducer() - catch - error( - "Invalid MeteoBindings reducer type for variable `$(target_var)` in process `$(process)` at scale `$(scale)`: ", - "type `$(reducer)` cannot be instantiated without arguments." - ) - end - else - (reducer isa PlantMeteo.AbstractTimeReducer || reducer isa Function) || error( - "Invalid MeteoBindings reducer for variable `$(target_var)` in process `$(process)` at scale `$(scale)`: ", - "`reducer` must be a reducer instance/type or a callable." - ) - end - end - return nothing - end - - error( - "Invalid MeteoBindings value for variable `$(target_var)` in process `$(process)` at scale `$(scale)`: ", - "unsupported type `$(typeof(binding))`." - ) -end -function _validate_meteo_bindings_for_spec(scale::Symbol, process::Symbol, spec::ModelSpec) - bindings = meteo_bindings(spec) - bindings isa NamedTuple || error( - "MeteoBindings for process `$(process)` at scale `$(scale)` must be a NamedTuple, got `$(typeof(bindings))`." - ) - - for (target_var, binding) in pairs(bindings) - target_var isa Symbol || error( - "MeteoBindings key for process `$(process)` at scale `$(scale)` must be a Symbol, got `$(typeof(target_var))`." - ) - _validate_meteo_binding(scale, process, target_var, binding) - end - return nothing -end - -function _validate_meteo_window_for_spec(scale::Symbol, process::Symbol, spec::ModelSpec) - window = meteo_window(spec) - isnothing(window) && return nothing - - window isa PlantMeteo.AbstractSamplingWindow || error( - "MeteoWindow for process `$(process)` at scale `$(scale)` must be a PlantMeteo sampling-window instance, got `$(typeof(window))`." - ) - - if window isa PlantMeteo.CalendarWindow - window.period in _CALENDAR_PERIODS || error( - "Invalid CalendarWindow period `$(window.period)` for process `$(process)` at scale `$(scale)`. ", - "Allowed values are $(_CALENDAR_PERIODS)." - ) - window.anchor in _CALENDAR_ANCHORS || error( - "Invalid CalendarWindow anchor `$(window.anchor)` for process `$(process)` at scale `$(scale)`. ", - "Allowed values are $(_CALENDAR_ANCHORS)." - ) - 1 <= window.week_start <= 7 || error( - "Invalid CalendarWindow week_start `$(window.week_start)` for process `$(process)` at scale `$(scale)`. ", - "Allowed values are integers in 1:7." - ) - window.completeness in _CALENDAR_COMPLETENESS || error( - "Invalid CalendarWindow completeness `$(window.completeness)` for process `$(process)` at scale `$(scale)`. ", - "Allowed values are $(_CALENDAR_COMPLETENESS)." - ) - end - - return nothing -end - -""" - validate_model_specs_configuration(model_specs) - -Validate mapping-level `ModelSpec` configuration before simulation runtime starts. -This catches invalid timestep declarations, input bindings and output routing early. -""" -function validate_model_specs_configuration(model_specs) - known_processes = Set{Symbol}() - for specs_at_scale in values(model_specs) - union!(known_processes, keys(specs_at_scale)) - end - - for (scale, specs_at_scale) in pairs(model_specs) - for (process, spec) in pairs(specs_at_scale) - _validate_timestep_spec(scale, process, spec) - _validate_scope_spec(scale, process, spec) - _validate_input_bindings_for_spec(scale, process, spec, model_specs, known_processes) - _validate_meteo_bindings_for_spec(scale, process, spec) - _validate_meteo_window_for_spec(scale, process, spec) - _validate_output_routing_for_spec(scale, process, spec) - end - end - - return nothing -end diff --git a/src/mtg/save_results.jl b/src/mtg/save_results.jl deleted file mode 100644 index 8230f534a..000000000 --- a/src/mtg/save_results.jl +++ /dev/null @@ -1,371 +0,0 @@ -""" - pre_allocate_outputs(statuses, outs, nsteps; check=true) - -Pre-allocate the outputs of needed variable for each node type in vectors of vectors. -The first level vectors have length nsteps, and the second level vectors have length n_nodes of this type. - -Note that we pre-allocate the vectors for the time-steps, but not for each organ, because we don't -know how many nodes will be in each organ in the future (organs can appear or disapear). - -# Arguments - -- `statuses`: a dictionary of status by node type -- `outs`: a dictionary of outputs by node type -- `nsteps`: the number of time-steps -- `check`: whether to check the mapping for errors. Default (`true`) returns an error if some variables do not exist. -If false and some variables are missing, return an info, remove the unknown variables and continue. - -# Returns - -- A dictionary of pre-allocated output of vector of time-step and vector of node of that type. - -# Examples - -```jldoctest mylabel -julia> using PlantSimEngine, MultiScaleTreeGraph, PlantSimEngine.Examples -``` - -Import example models (can be found in the `examples` folder of the package, or in the `Examples` sub-modules): - -```jldoctest mylabel -julia> using PlantSimEngine.Examples; -``` - -Define the models mapping: - -```jldoctest mylabel -julia> mapping = ModelMapping( \ - :Plant => ( \ - MultiScaleModel( \ - model=ToyCAllocationModel(), \ - mapped_variables=[ \ - :carbon_assimilation => [:Leaf], \ - :carbon_demand => [:Leaf, :Internode], \ - :carbon_allocation => [:Leaf, :Internode] \ - ], \ - ), - MultiScaleModel( \ - model=ToyPlantRmModel(), \ - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],] \ - ), \ - ),\ - :Internode => ( \ - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), \ - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), \ - Status(TT=10.0, carbon_biomass=1.0) \ - ), \ - :Leaf => ( \ - MultiScaleModel( \ - model=ToyAssimModel(), \ - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], \ - ), \ - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), \ - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), \ - Status(aPPFD=1300.0, TT=10.0, carbon_biomass=1.0), \ - ), \ - :Soil => ( \ - ToySoilWaterModel(), \ - ), \ -); -``` - -Importing an example MTG provided by the package: - -```jldoctest mylabel -julia> mtg = import_mtg_example(); -``` - -```jldoctest mylabel -julia> statuses, status_templates, reverse_multiscale_mapping, vars_need_init = PlantSimEngine.init_statuses(mtg, mapping); -``` - -```jldoctest mylabel -julia> outs = Dict(:Leaf => (:carbon_assimilation, :carbon_demand), :Soil => (:soil_water_content,)); -``` - -Pre-allocate the outputs as a dictionary: - -```jldoctest mylabel -julia> preallocated_vars = PlantSimEngine.pre_allocate_outputs(statuses, status_templates, reverse_multiscale_mapping, vars_need_init, outs, 2); -``` - -The dictionary has a key for each organ from which we want outputs: - -```jldoctest mylabel -julia> collect(keys(preallocated_vars)) -2-element Vector{Symbol}: - :Soil - :Leaf -``` - -Each organ has a dictionary of variables for which we want outputs from, -with the pre-allocated empty vectors (one per time-step that will be filled with one value per node): - -```jldoctest mylabel -julia> collect(keys(preallocated_vars[:Leaf])) -3-element Vector{Symbol}: - :carbon_assimilation - :node - :carbon_demand -``` -""" - -function pre_allocate_outputs(statuses, statuses_template, reverse_multiscale_mapping, vars_need_init, outs, nsteps; type_promotion=nothing, check=true) - outs_ = Dict{Symbol,Vector{Symbol}}() - - # default behaviour : track everything - if isnothing(outs) - for organ in keys(statuses) - outs_[organ] = [keys(statuses_template[organ])...] - end - # No outputs requested by user : just return the timestep and node - elseif length(outs) == 0 - for i in keys(statuses) - outs_[i] = [] - end - else - for i in keys(outs) # i = :Plant - i isa Symbol || error("Output scale keys must be `Symbol`, got `$(typeof(i))` for key `$(repr(i))`.") - @assert isa(outs[i], Tuple{Vararg{Symbol}}) """Outputs for scale $i should be a tuple of symbols, *e.g.* `$i => (:a, :b)`, found `$i => $(outs[i])` instead.""" - outs_[i] = [outs[i]...] - end - end - - len = Dict{Symbol,Int}() - for (organ, vals) in outs_ - len[organ] = length(outs_[organ]) - unique!(outs_[organ]) - end - - - for (organ, vals) in outs_ - if length(outs_[organ]) != len[organ] - @info "One or more requested output variable duplicated at scale $organ, removed it" - end - end - - statuses_ = copy(statuses_template) - # Checking that organs in outputs exist in the mtg (in the statuses): - if !all(i in keys(statuses) for i in keys(outs_)) - not_in_statuses = setdiff(keys(outs_), keys(statuses)) - e = string( - "You requested outputs for organs ", - join(keys(outs_), ", "), - ", but organs ", - join(not_in_statuses, ", "), - " have no models." - ) - - if check - error(e) - else - @info e - [delete!(outs_, i) for i in not_in_statuses] - end - end - - # Checking that variables in outputs exist in the statuses, and adding the :node variable: - for (organ, vars) in outs_ # organ = :Leaf; vars = outs_[organ] - if length(statuses[organ]) == 0 - # The organ is not found in the mtg, we return an info and get along (it might be created during the simulation): - check && @info "You required outputs for organ $organ, but this organ is not found in the provided MTG at this point." - end - if !all(i in collect(keys(statuses_[organ])) for i in vars) - not_in_statuses = (setdiff(vars, keys(statuses_[organ]))...,) - plural = length(not_in_statuses) == 1 ? "" : "s" - e = string( - "You requested outputs for variable", plural, " ", - join(not_in_statuses, ", "), - " in organ $organ, but ", - length(not_in_statuses) == 1 ? "it has no model." : "they have no models." - ) - if check - error(e) - else - @info e - existing_vars_requested = setdiff(outs_[organ], not_in_statuses) - if length(existing_vars_requested) == 0 - # None of the variables requested by the user exist at this scale for this set of models - delete!(outs_, organ) - else - # Some still exist, we only use the ones that do: - outs_[organ] = [existing_vars_requested...] - end - end - end - - if :node ∉ outs_[organ] - push!(outs_[organ], :node) - end - end - - node_types = [] - for o in keys(statuses) - if length(statuses[o]) > 0 - push!(node_types, typeof(statuses[o][1].node)) - end - end - - node_type = unique(node_types) - @assert length(node_type) == 1 "All plant graph nodes should have the same type, found $(unique(node_type))." - node_type = only(node_type) - - # I don't know if this function barrier is necessary - preallocated_outputs = Dict{Symbol,Vector}() - complete_preallocation_from_types!(preallocated_outputs, nsteps, outs_, node_type, statuses_template) - return preallocated_outputs -end - -function complete_preallocation_from_types!(preallocated_outputs, nsteps, outs_, node_type, statuses_template) - types = Vector{DataType}() - for organ in keys(outs_) - - outs_no_node = filter(x -> x != :node, outs_[organ]) - - #types = [typeof(status_from_template(statuses_template[organ])[var]) for var in outs[organ]] - values = [status_from_template(statuses_template[organ])[var] for var in outs_no_node] - - #push!(types, node_type) - - # contains :node - symbols_tuple = (:timestep, :node, outs_no_node...,) - # using node_type.parameters[1] is clunky, but covers both NodeMTG and AbstractNodeMTG types - values_tuple = (1, MultiScaleTreeGraph.Node((node_type.parameters[1])("/", "Uninitialized", 0, 0),), values...,) - - # Dummy value to make accessing the type easier - # (empty arrays don't have references to an instance, so their types can't be inspected and manipulated as easily) - dummy_status = (; zip(symbols_tuple, values_tuple)...) - data = typeof(Status(dummy_status))[] - resize!(data, nsteps) - - for ii in 1:nsteps - data[ii] = Status(dummy_status) - end - preallocated_outputs[organ] = data - end -end - - -""" - save_results!(object::GraphSimulation, i) - -Save the results of the simulation for time-step `i` into the -object. For a `GraphSimulation` object, this will save the results -from the `status(object)` in the `outputs(object)`. -""" -function save_results!(object::GraphSimulation, i) - outs = outputs(object) - - if length(outs) == 0 - return - end - - statuses = status(object) - indexes = object.outputs_index - for organ in keys(outs) - - if length(outs[organ]) == 0 - continue - end - - index = indexes[organ] - - # Samuel : Simple resizing heuristic - # This can be made much more conservative with the right heuristic, or with user hints - # The array filling bit of code is clunky, but building NamedTuples on the fly was tanking performance - # And it wasn't straightforward to avoid Status Ref reallocations causing performance issues - # I then tried various approaches with Status, resizing, using fill, deepcopying, ... - # I may have gotten a little confused while fighting the type system - # So there may be possible simplifications (maybe no need for a function barrier, perhaps the resizing could be made a one-liner...) - # But this should work without causing visible performance regressions on XPalm - len = length(outs[organ]) - if length(statuses[organ]) + index - 1 > len - min_required = max(length(statuses[organ]) + index - len, index) - - extra_length = 2 * min_required - len - data = eltype(outs[organ])[] - resize!(data, extra_length) - dummy_value = NamedTuple(outs[organ][1]) - # TODO set timestep to 0 for clarity ? - - # Using fill! caused Ref issues, so call a Status constructor here instead of passing a prebuilt value - # This will avoid having all array entries point to the same ref but keep construction cost at a minimum - for new_entry in 1:extra_length - data[new_entry] = Status(dummy_value) - end - - outs[organ] = cat(outs[organ], data, dims=1) - #println("len : ", len, " statuses #", length(statuses[organ]), " index ", index) - #println("min_required : ", min_required, " extra_length ", extra_length, " new len ", length(outs[organ])) - end - - tracked_outputs = filter(i -> i != :timestep, keys(outs[organ][1])) - - indexes[organ] = copy_tracked_outputs_into_vector!(outs[organ], i, statuses[organ], tracked_outputs, indexes[organ]) - end -end - -function copy_tracked_outputs_into_vector!(outs_organ, i, statuses_organ, tracked_outputs, index) - j = index - for status in statuses_organ - outs_organ[j].timestep = i - for var in tracked_outputs - outs_organ[j][var] = status[var] - end - j += 1 - end - return j -end - -function pre_allocate_outputs(m::ModelList, outs, nsteps; type_promotion=nothing, check=true) - st, = flatten_status(status(m)) - out_vars_all = convert_vars(st, type_promotion) - - out_keys_requested = Symbol[] - if !isnothing(outs) - if length(outs) == 0 # no outputs desired, for some reason - return NamedTuple() - end - out_keys_requested = Symbol[outs...] - end - out_vars_requested = NamedTuple() - - # default implicit behaviour, track everything - if isempty(out_keys_requested) - # We already have the status here, just repeating its value: - out_vars_requested = NamedTuple(out_vars_all) - else - unexpected_outputs = setdiff(out_keys_requested, keys(st)) - - if !isempty(unexpected_outputs) - e = string( - "You requested as output ", - join(unexpected_outputs, " ,"), - " not found in any model." - ) - - if check - error(e) - else - @info e - [delete!(unexpected_outputs, i) for i in unexpected_outputs] - end - end - - out_defaults_requested = (out_vars_all[i] for i in out_keys_requested) - out_vars_requested = (; zip(out_keys_requested, out_defaults_requested)...) - end - - return TimeStepTable([Status(out_vars_requested) for i in Base.OneTo(nsteps)]) -end - -function save_results!(status_flattened::Status, outputs, i) - if length(outputs) == 0 - return - end - outs = outputs[i] - - for var in keys(outs) - setproperty!(outs, var, status_flattened[var]) - end -end diff --git a/src/processes/model_initialisation.jl b/src/processes/model_initialisation.jl deleted file mode 100755 index 7c999d903..000000000 --- a/src/processes/model_initialisation.jl +++ /dev/null @@ -1,384 +0,0 @@ -""" - to_initialize(; verbose=true, vars...) - to_initialize(m::T) where T <: ModelMapping - to_initialize(m::DependencyGraph) - to_initialize(mapping::ModelMapping, graph=nothing) - to_initialize(mapping::AbstractDict{Symbol,T}, graph=nothing) - -Return the variables that must be initialized providing a set of models and processes. The -function takes into account model coupling and only returns the variables that are needed -considering that some variables that are outputs of some models are used as inputs of others. - -# Arguments - -- `verbose`: if `true`, print information messages. -- `vars...`: the models and processes to consider. -- `m::T`: a [`ModelMapping`](@ref). -- `m::DependencyGraph`: a [`DependencyGraph`](@ref). -- `mapping::ModelMapping` (or dictionary-like mapping): associates models to organs/scales. -- `graph`: a graph representing a plant or a scene, *e.g.* a multiscale tree graph. The graph - is used to check if variables that are not initialized can be found in the graph nodes attributes. - -# Examples - -```@example -using PlantSimEngine - -# Load the dummy models given as example in the package: -using PlantSimEngine.Examples - -to_initialize(process1=Process1Model(1.0), process2=Process2Model()) - -# Or using a component directly: -models = ModelMapping(process1=Process1Model(1.0), process2=Process2Model()) -to_initialize(models) - -m = ModelMapping( - ( - process1=Process1Model(1.0), - process2=Process2Model() - ), - Status(var1 = 5.0, var2 = -Inf, var3 = -Inf, var4 = -Inf, var5 = -Inf) -) - -to_initialize(m) -``` - -Or with a mapping: - -```@example -using PlantSimEngine - -# Load the dummy models given as example in the package: -using PlantSimEngine.Examples - -mapping = ModelMapping( - :Leaf => ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model() - ), - :Internode => ModelMapping( - process1=Process1Model(1.0), - ) -) - -to_initialize(mapping) -``` -""" -function to_initialize(m::ModelList) - needed_variables = to_initialize(dep(m)) - to_init = Dict{Symbol,Tuple}() - for (process, vars) in needed_variables - # default_values = needed_variables[:process1] - # st = m.status - not_init = vars_not_init_(m.status, vars) - length(not_init) > 0 && push!(to_init, process => not_init) - end - return NamedTuple(to_init) -end - -function to_initialize(m::DependencyGraph) - dependencies = traverse_dependency_graph(m, to_initialize) - - outputs_all = Set{Symbol}() - for (key, value) in dependencies - outputs_all = union(outputs_all, keys(value.outputs)) - end - - needed_variables_process = Dict{Symbol,NamedTuple}() - for (key, value) in dependencies - for (key_in, val_in) in pairs(value.inputs) - if key_in ∉ outputs_all - if haskey(needed_variables_process, key) - needed_variables_process[key] = merge(needed_variables_process[key], NamedTuple{(key_in,)}(val_in)) - else - push!(needed_variables_process, key => NamedTuple{(key_in,)}(val_in)) - end - end - end - end - # note: needed_variables_process is e.g.: - # Dict{Symbol, NamedTuple} with 2 entries: - # :process1 => (var1 = -Inf, var2 = -Inf) - # :process2 => (var1 = -Inf,) - return needed_variables_process -end - - -#Return the variables that must be initialized providing a set of models and processes. The -#function just returns the inputs and outputs of each model, with their default values. -#To take into account model coupling, use the function at an upper-level instead, *i.e.* -# `to_initialize(m::ModelMapping)` or `to_initialize(m::DependencyGraph)`. -function to_initialize(m::AbstractDependencyNode) - return (inputs=inputs_(m.value), outputs=outputs_(m.value)) -end - -function to_initialize(m::T) where {T<:Dict{Symbol,ModelMapping}} - toinit = Dict{Symbol,NamedTuple}() - for (key, value) in m - # key = :Leaf; value = m[key] - toinit_ = to_initialize(value) - - if length(toinit_) > 0 - push!(toinit, key => toinit_) - end - end - - return toinit -end - - -function to_initialize(; verbose=true, vars...) - needed_variables = to_initialize(dep(; verbose=verbose, (; vars...)...)) - to_init = Dict{Symbol,Tuple}() - for (process, vars) in pairs(needed_variables) - not_init = keys(vars) - length(not_init) > 0 && push!(to_init, process => not_init) - end - return NamedTuple(to_init) -end - -# For the list of mapping given to an MTG: -function to_initialize(mapping::AbstractDict{Symbol,T}, graph=nothing) where {T} - # Get the variables in the MTG: - if isnothing(graph) - vars_in_mtg = Symbol[] - else - vars_in_mtg = names(graph) - end - - to_init = Dict(org => Symbol[] for org in keys(mapping)) - mapped_vars = mapped_variables(mapping, first(hard_dependencies(mapping; verbose=false)), verbose=false) - for (org, vars) in mapped_vars - for (var, val) in vars - if isa(val, UninitializedVar) && var ∉ vars_in_mtg - push!(to_init[org], var) - end - end - end - - filter!(x -> length(last(x)) > 0, to_init) - - return to_init -end - -""" - init_status!(object::Dict{Symbol,ModelMapping};vars...) - init_status!(component::ModelMapping;vars...) - -Initialise model variables for components with user input. - -# Examples - -```@example -using PlantSimEngine - -# Load the dummy models given as example in the package: -using PlantSimEngine.Examples - -models = Dict( - :Leaf => ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model() - ), - :Internode => ModelMapping( - process1=Process1Model(1.0), - ) -) - -init_status!(models, var1=1.0 , var2=2.0) -status(models[:Leaf]) -``` -""" -function init_status!(object::Dict{Symbol,ModelMapping}; vars...) - new_vals = (; vars...) - - for (component_name, component) in object - for j in keys(new_vals) - if !in(j, keys(component.status)) - @info "Key $j not found as a variable for any provided models in $component_name" maxlog = 1 - continue - end - setproperty!(component.status, j, new_vals[j]) - end - end -end - -function init_status!(component::T; vars...) where {T<:ModelMapping} - new_vals = (; vars...) - for j in keys(new_vals) - if !in(j, keys(component.status)) - @info "Key $j not found as a variable for any provided models" - continue - end - setproperty!(component.status, j, new_vals[j]) - end -end - -""" - init_variables(models...) - -Initialized model variables with their default values. The variables are taken from the -inputs and outputs of the models. - -# Examples - -```@example -using PlantSimEngine - -# Load the dummy models given as example in the package: -using PlantSimEngine.Examples - -init_variables(Process1Model(2.0)) -init_variables(process1=Process1Model(2.0), process2=Process2Model()) -``` -""" -function init_variables(model::T; verbose::Bool=true) where {T<:AbstractModel} - # Only one model is provided: - in_vars = inputs_(model) - out_vars = outputs_(model) - # Merge both: - vars = merge(in_vars, out_vars) - - return vars -end - -function init_variables(m::ModelList; verbose::Bool=true) - init_variables(dep(m)) -end - -function init_variables(m::DependencyGraph) - dependencies = traverse_dependency_graph(m, init_variables) - return NamedTuple(dependencies) -end - -function init_variables(node::AbstractDependencyNode) - return init_variables(node.value) -end - -# Models are provided as keyword arguments: -function init_variables(; verbose::Bool=true, kwargs...) - mods = (; kwargs...) - init_variables(dep(; verbose=verbose, mods...)) -end - -# Models are provided as a NamedTuple: -function init_variables(models::T; verbose::Bool=true) where {T<:NamedTuple} - init_variables(dep(; verbose=verbose, models...)) -end - -""" - is_initialized(m::T) where T <: ModelMapping - is_initialized(m::T, models...) where T <: ModelMapping - -Check if the variables that must be initialized are, and return `true` if so, and `false` and -an information message if not. - -# Note - -There is no way to know before-hand which process will be simulated by the user, so if you -have a component with a model for each process, the variables to initialize are always the -smallest subset of all, meaning it is considered the user will simulate the variables needed -for other models. - -# Examples - -```@example -using PlantSimEngine - -# Load the dummy models given as example in the package: -using PlantSimEngine.Examples - -models = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model() -) - -is_initialized(models) -``` -""" -function is_initialized(m::T; verbose=true) where {T<:ModelList} - var_names = to_initialize(m) - - if any([length(to_init) > 0 for (process, to_init) in pairs(var_names)]) - verbose && @info "Some variables must be initialized before simulation: $var_names (see `to_initialize()`)" maxlog = 1 - return false - else - return true - end -end - -function is_initialized(models...; verbose=true) - var_names = to_initialize(models...) - if length(var_names) > 0 - verbose && @info "Some variables must be initialized before simulation: $(var_names) (see `to_initialize()`)" maxlog = 1 - return false - else - return true - end -end - -""" - vars_not_init_(st<:Status, var_names) - -Get which variable is not properly initialized in the status struct. -""" -function vars_not_init_(st::T, default_values) where {T<:Status} - length(default_values) == 0 && return () # no variables - - not_init = Symbol[] - for i in keys(default_values) - # if the variable value is equal to the default value, or if it is an uninitialized RefVector (length == 0): - if getproperty(st, i) == default_values[i] || (isa(getproperty(st, i), RefVector) && length(getproperty(st, i)) == 0) - push!(not_init, i) - end - end - return (not_init...,) -end - -# For components with a status with multiple time-steps: -function vars_not_init_(status, default_values) - length(default_values) == 0 && return () # no variables - - not_init = Set{Symbol}() - for st in Tables.rows(status), i in eachindex(default_values) - if getproperty(st, i) == getproperty(default_values, i) - push!(not_init, i) - end - end - - return Tuple(not_init) -end - -""" - init_variables_manual(models...;vars...) - -Return an initialisation of the model variables with given values. - -# Examples - -```@example -using PlantSimEngine - -# Load the dummy models given as example in the package: -using PlantSimEngine.Examples - -models = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model() -) - -PlantSimEngine.init_variables_manual(status(models), (var1=20.0,)) -``` -""" -function init_variables_manual(status, vars) - for i in keys(vars) - !in(i, keys(status)) && error("Key $i not found as a variable of the status.") - setproperty!(status, i, vars[i]) - end - status -end diff --git a/src/processes/models_inputs_outputs.jl b/src/processes/models_inputs_outputs.jl index 02bac7eb1..1f26f96b1 100644 --- a/src/processes/models_inputs_outputs.jl +++ b/src/processes/models_inputs_outputs.jl @@ -59,30 +59,60 @@ or `Interpolate`) for each producer output. output_policy(model::AbstractModel) = output_policy(typeof(model)) output_policy(::Type{<:AbstractModel}) = NamedTuple() +function _policy_for_output(model, variable::Symbol) + policies = output_policy(model) + variable in keys(policies) || return HoldLast() + return _as_schedule_policy( + policies[variable]; + context="output_policy for `$(variable)` in $(typeof(model))", + ) +end + +function _publish_mode_for_output(spec::ModelSpec, variable::Symbol) + modes = output_routing(spec) + mode = variable in keys(modes) ? modes[variable] : :canonical + mode in (:canonical, :stream_only) || error( + "Unsupported output routing mode `$(mode)` for `$(variable)`." + ) + return mode +end + +""" + application_name(spec::ModelSpec) + +Optional stable name for one model application in the unified composite-model/object API. +""" +application_name(spec::ModelSpec) = spec.name + +""" + applies_to(spec::ModelSpec) + +Object selector where a model application runs in the unified composite-model/object API. """ - input_bindings(spec::ModelSpec) +applies_to(spec::ModelSpec) = spec.applies_to -Optional explicit input-to-producer bindings used by multi-rate resolution. +""" + value_inputs(spec::ModelSpec) -`ModelSpec` is the user-facing API for mapping-level coupling configuration. -Bindings are read from `ModelSpec` during multiscale multi-rate simulation. +Unified composite-model/object value-input bindings declared with `Inputs(...)`. +""" +value_inputs(spec::ModelSpec) = spec.inputs +input_origins(spec::ModelSpec) = spec.input_origins -Expected return value is a `NamedTuple` keyed by consumer input variable names. -Each value must itself be a `NamedTuple` with: -- `process::Symbol`: producer process name (as generated by `@process`) -- `var::Symbol`: producer output variable name -- `policy`: scheduling policy instance (defaults to `HoldLast()` when omitted) +""" + model_calls(spec::ModelSpec) -This lets a consumer input read from a producer variable even when names differ. -For example, consumer input `:C` can be mapped from producer output `:S`. +Unified composite-model/object manual call bindings declared with `Calls(...)`. +""" +model_calls(spec::ModelSpec) = spec.calls +call_origins(spec::ModelSpec) = spec.call_origins -# Example +""" + environment_config(spec::ModelSpec) -```julia -InputBindings(; C=(process=:myproducer, var=:S)) -``` +Optional composite-model/object environment configuration declared with `Environment(...)`. """ -input_bindings(spec::ModelSpec) = spec.input_bindings +environment_config(spec::ModelSpec) = spec.environment """ output_routing(spec::ModelSpec) @@ -95,17 +125,17 @@ Allowed values are: output_routing(spec::ModelSpec) = spec.output_routing """ - model_scope(spec::ModelSpec) + updates(spec::ModelSpec) -Scope selector used by multi-rate runtime to partition producer streams. -Default is `:global`. +Scenario-level metadata for variables intentionally updated by this model after +another producer on the same object. """ -model_scope(spec::ModelSpec) = spec.scope +updates(spec::ModelSpec) = spec.updates """ meteo_bindings(spec::ModelSpec) -Optional explicit weather aggregation bindings used by multi-rate MTG runtime. +Optional explicit weather aggregation bindings used by the model runtime. Each key is the target meteo variable exposed to the model at execution time. Each value can be: - PlantMeteo reducer instance/type (e.g. `MeanWeighted()`, `MaxReducer`) @@ -117,26 +147,36 @@ meteo_bindings(spec::ModelSpec) = spec.meteo_bindings """ meteo_window(spec::ModelSpec) -Optional weather window-selection strategy used by multi-rate MTG runtime. +Optional weather window-selection strategy used by the model runtime. Defaults to `nothing` (runtime falls back to `PlantMeteo.RollingWindow()` behavior). """ meteo_window(spec::ModelSpec) = spec.meteo_window """ - inputs(mapping::ModelMapping) - inputs(mapping::AbstractDict{Symbol,T}) + meteo_inputs(model::AbstractModel) + meteo_inputs_(model::AbstractModel) + +Meteorological/environment variables read directly by a model. + +This trait is separate from `inputs_` because meteorology may be constant, +table-backed, or produced by a microclimate backend. The default is empty. +""" +meteo_inputs(model::AbstractModel) = keys(meteo_inputs_(model)) +meteo_inputs(spec::ModelSpec) = keys(meteo_inputs_(spec)) +meteo_inputs_(model::AbstractModel) = NamedTuple() +meteo_inputs_(model::Missing) = NamedTuple() -Get the inputs of the models in a mapping, for each process and organ type. """ -function inputs(mapping::AbstractDict{Symbol,T}) where {T} - vars = Dict{Symbol,NamedTuple}() - for organ in keys(mapping) - mods = pairs(parse_models(get_models(mapping[organ]))) - push!(vars, organ => (; (i.first => (inputs(i.second)...,) for i in mods)...)) - end - return vars -end + meteo_outputs(model::AbstractModel) + meteo_outputs_(model::AbstractModel) +Meteorological/environment variables produced by a model, for example local +microclimate variables computed over a voxel or octree backend. +""" +meteo_outputs(model::AbstractModel) = keys(meteo_outputs_(model)) +meteo_outputs(spec::ModelSpec) = keys(meteo_outputs_(spec)) +meteo_outputs_(model::AbstractModel) = NamedTuple() +meteo_outputs_(model::Missing) = NamedTuple() """ outputs(model::AbstractModel) @@ -168,22 +208,6 @@ function outputs(v::T, vars...) where {T<:AbstractModel} length((vars...,)) > 0 ? union(outputs(v), outputs(vars...)) : outputs(v) end -""" - outputs(mapping::ModelMapping) - outputs(mapping::AbstractDict{Symbol,T}) - -Get the outputs of the models in a mapping, for each process and organ type. -""" -function outputs(mapping::AbstractDict{Symbol,T}) where {T} - vars = Dict{Symbol,NamedTuple}() - for organ in keys(mapping) - mods = pairs(parse_models(get_models(mapping[organ]))) - push!(vars, organ => (; (i.first => (outputs(i.second)...,) for i in mods)...)) - end - return vars -end - - function outputs_(model::AbstractModel) NamedTuple() end @@ -228,16 +252,6 @@ function variables(m::T, ms...) where {T<:Union{Missing,AbstractModel}} length((ms...,)) > 0 ? merge(variables(m), variables(ms...)) : merge(inputs_(m), outputs_(m)) end -function variables(m::SoftDependencyNode) - self_variables = (inputs=inputs_(m.value), outputs=outputs_(m.value)) - # hard_dep_vars = map(variables, m.hard_dependencies) - return self_variables -end - -function variables(m::HardDependencyNode) - return (inputs=inputs_(m.value), outputs=outputs_(m.value)) -end - """ variables(pkg::Module) @@ -265,20 +279,12 @@ function variables(pkg::Module) end """ - variables(mapping::ModelMapping) - variables(mapping::AbstractDict{Symbol,T}) + init_variables(model) -Get the variables (inputs and outputs) of the models in a mapping, for each -process and organ type. +Return the merged default input and output values declared by `model`. """ -function variables(mapping::AbstractDict{Symbol,T}) where {T} - vars = Dict{Symbol,NamedTuple}() - for organ in keys(mapping) - mods = pairs(parse_models(get_models(mapping[organ]))) - push!(vars, organ => (; (i.first => (; variables(i.second)...,) for i in mods)...)) - end - return vars -end +init_variables(model::AbstractModel; verbose::Bool=true) = variables(model) +init_variables(spec::ModelSpec; verbose::Bool=true) = init_variables(model_(spec); verbose=verbose) """ variables_typed(model) diff --git a/src/processes/process_generation.jl b/src/processes/process_generation.jl index 9bd1e90e8..993ff12fd 100644 --- a/src/processes/process_generation.jl +++ b/src/processes/process_generation.jl @@ -124,7 +124,9 @@ macro process(f, args...) inside your process implementation: ```julia - PlantSimEngine.dep(::$(dummy_type_name)) = (other_process_name=AbstractOtherProcessModel,) + PlantSimEngine.dep(::$(dummy_type_name)) = ( + other_process_name=Call(process=:other_process_name), + ) ``` And finally, we can define the model implementation by adding a method to `run!`: @@ -138,41 +140,28 @@ macro process(f, args...) constants, extra ) - status.Y = model.$(process_name).a * meteo.CO2 + status.X - run!(model.other_process_name, models, status, meteo, constants, extra) + status.Y = models.$(process_name).a * meteo.CO2 + status.X + run_call!(extra, :other_process_name; meteo=meteo, publish=true) end ``` - Note that {#8abeff}run!(){/#8abeff} takes six arguments: the model type (used for dispatch), the ModelMapping, the status, the meteorology, + Note that {#8abeff}run!(){/#8abeff} takes six arguments: the model type (used for dispatch), the called-model bundle, the status, the meteorology, the constants and any extra values. - Then we can use variables from the status as inputs or outputs, model parameters from the ModelMapping (indexing by process, here + Then we can use variables from the status as inputs or outputs, and model parameters from the called-model bundle (indexing by process, here using "$(process_name)" as the process name), and meteorology variables. - Note that our example model has an hard-dependency on another process called `other_process_name` that is called using the {#8abeff}run!(){/#8abeff} function with - the process as the first argument: `run!(model.other_process_name, models, status, meteo, constants, extra)`. - - If your model can be run in parallel, you can also add traits to your model type so `PlantSimEngine` knows - it can safely parallelize the computation: - - - over space (*i.e.* over objects): - - ```@example usepkg - PlantSimEngine.ObjectDependencyTrait(::Type{<:$(dummy_type_name)}) = PlantSimEngine.IsObjectIndependent() - ``` - - - over time (*i.e.* time-steps): - - ```@example usepkg - PlantSimEngine.TimeStepDependencyTrait(::Type{<:$(dummy_type_name)}) = PlantSimEngine.IsTimeStepIndependent() - ``` + Our example model has a hard dependency on `other_process_name`. The + compiled runtime resolves its declared targets, executes them with + `run_call!(extra, :other_process_name; meteo=meteo, publish=true)`, and + returns a vector-like `CallTargets` collection. !!! tip "Variables and parameters usage" Note that {#8abeff}run!(){/#8abeff} takes six arguments: the model type (used - for dispatch), the ModelMapping, the status, the meteorology, the constants and + for dispatch), the called-model bundle, the status, the meteorology, the constants and any extra values. Then we can use variables from the status as inputs or outputs, model parameters - from the ModelMapping (indexing by process, here using "$(process_name)" as the + from the called-model bundle (indexing by process, here using "$(process_name)" as the process name), and meteorology variables. """ ) diff --git a/src/run.jl b/src/run.jl deleted file mode 100644 index 99cbe6d15..000000000 --- a/src/run.jl +++ /dev/null @@ -1,773 +0,0 @@ -""" - run!(object, meteo, constants, extra=nothing; check=true, executor=Floops.ThreadedEx()) - run!(object, mapping, meteo, constants, extra; nsteps, outputs, check, executor) - -Run the simulation for each model in the model list in the correct order, *i.e.* respecting -the dependency graph. - -If several time-steps are given, the models are run sequentially for each time-step. - -# Arguments - -- `object`: a [`ModelMapping`](@ref) for single-scale runs, or a plant graph (MTG) for multiscale runs. -- `meteo`: a [`PlantMeteo.TimeStepTable`](https://palmstudio.github.io/PlantMeteo.jl/stable/API/#PlantMeteo.TimeStepTable) of -[`PlantMeteo.Atmosphere`](https://palmstudio.github.io/PlantMeteo.jl/stable/API/#PlantMeteo.Atmosphere) or a single `PlantMeteo.Atmosphere`. - When meteo is provided, `duration` must be present and strictly positive. -- `constants`: a [`PlantMeteo.Constants`](https://palmstudio.github.io/PlantMeteo.jl/stable/API/#PlantMeteo.Constants) object, or a `NamedTuple` of constant keys and values. -- `extra`: extra parameters, not available for simulation of plant graphs (the simulation object is passed using this). -- `check`: if `true`, check the validity of the model list before running the simulation (takes a little bit of time), and return more information while running. -- `executor`: the [`Floops`](https://juliafolds.github.io/FLoops.jl/stable/) executor used to run the simulation either in sequential (`executor=SequentialEx()`), in a -multi-threaded way (`executor=ThreadedEx()`, the default), or in a distributed way (`executor=DistributedEx()`). -- `mapping`: a [`ModelMapping`](@ref) between MTG scales and models. -- `nsteps`: the number of time-steps to run, only needed if no meteo is given (else it is infered from it). -- `outputs`: the outputs to get in dynamic for each node type of the MTG. -- `return_requested_outputs`: when `true` in MTG multi-rate runs, return requested resampled outputs directly - as second return value. -- `requested_outputs_sink`: sink used to materialize requested outputs when `return_requested_outputs=true`. - -# Returns - -Returns status outputs (and optionally requested exports). -For MTG multi-rate runs with `return_requested_outputs=true`, returns -`(status_outputs, requested_outputs)`. - -# Details - -## Model execution - -The models are run according to the dependency graph. If a model has a soft dependency on another -model (*i.e.* its inputs are computed by another model), the other model is run first. If a model -has several soft dependencies, the parents (the soft dependencies) are always computed first. - -## Parallel execution - -Users can ask for parallel execution by providing a compatible executor to the `executor` argument. The package will also automatically -check if the execution can be parallelized. If it is not the case and the user asked for a parallel computation, it return a warning and run the simulation sequentially. -We use the [`Floops`](https://juliafolds.github.io/FLoops.jl/stable/) package to run the simulation in parallel. That means that you can provide any compatible executor to the `executor` argument. -You can take a look at [FoldsThreads.jl](https://github.com/JuliaFolds/FoldsThreads.jl) for extra thread-based executors, [FoldsDagger.jl](https://github.com/JuliaFolds/FoldsDagger.jl) for -Transducers.jl-compatible parallel fold implemented using the Dagger.jl framework, and soon [FoldsCUDA.jl](https://github.com/JuliaFolds/FoldsCUDA.jl) for GPU computations -(see [this issue](https://github.com/VirtualPlantLab/PlantSimEngine.jl/issues/22)) and [FoldsKernelAbstractions.jl](https://github.com/JuliaFolds/FoldsKernelAbstractions.jl). You can also take a look at -[ParallelMagics.jl](https://github.com/JuliaFolds/ParallelMagics.jl) to check if automatic parallelization is possible. - -# Example - -Import the packages: - -```jldoctest run -julia> using PlantSimEngine, PlantMeteo; -``` - -Load the dummy models given as example in the `Examples` sub-module: - -```jldoctest run -julia> using PlantSimEngine.Examples; -``` - -Create a model mapping: - -```jldoctest run -julia> mapping = ModelMapping(Process1Model(1.0), Process2Model(), Process3Model(); status = (var1=1.0, var2=2.0)); -``` - -Create meteo data: - -```jldoctest run -julia> meteo = Atmosphere(T=20.0, Wind=1.0, P=101.3, Rh=0.65, Ri_PAR_f=300.0); -``` - -Run the simulation: - -```jldoctest run -julia> outputs_sim = run!(mapping, meteo); -``` - -Get the results: - -```jldoctest run -julia> (outputs_sim[:var4],outputs_sim[:var6]) -([12.0], [41.95]) -``` -""" -run! - -function adjust_weather_timesteps_to_given_length(desired_length, meteo) - # This isn't ideal in terms of codeflow, but check_dimensions will kick in later - # And determine whether there is a status vector length discrepancy - - if DataFormat(meteo) == TableAlike() - meteo_adjusted = TimeStepTable{Atmosphere}(meteo) - if get_nsteps(meteo) == 1 - return Tables.rows(meteo_adjusted)[1] - end - return Tables.rows(meteo_adjusted) - end - - if isnothing(meteo) - return Weather(repeat([Atmosphere(NamedTuple())], desired_length)) - elseif get_nsteps(meteo) == 1 && desired_length > 1 - if isa(meteo, Atmosphere) - return Weather(repeat([meteo], desired_length)) - end - else - return meteo # If e.g. single Atmosphere - end -end - - -function _all_modellists_collection(object) - if isa(object, AbstractArray) - return all(x -> x isa ModelList || x isa ModelMapping{SingleScale}, object) - elseif isa(object, AbstractDict) - return all(x -> x isa ModelList || x isa ModelMapping{SingleScale}, values(object)) - end - return false -end - -_single_scale_runtime_object(object) = object -_single_scale_runtime_object(mapping::ModelMapping) = _modellist_from_model_mapping(mapping) - -function _modellist_from_model_mapping(mapping::ModelMapping{SingleScale}) - mapping.data -end - -function _modellist_from_model_mapping(::ModelMapping{MultiScale}) - error("This `ModelMapping` is a multiscale mapping. ", "Use `run!(mtg, mapping, ...)` for multiscale mappings.") -end - -_modellist_from_model_mapping(mapping::ModelList) = mapping - -function run!( - mapping::M, - meteo=nothing, - constants=PlantMeteo.Constants(), - extra=nothing; - tracked_outputs=nothing, - check=true, - executor=ThreadedEx(), - return_requested_outputs=false, - requested_outputs_sink=DataFrames.DataFrame -) where {M<:Union{ModelMapping{SingleScale},ModelList}} - _validate_meteo_duration(meteo) - model_list = _modellist_from_model_mapping(mapping) - _run_modellist_singleton( - model_list, - meteo, - constants, - extra; - tracked_outputs=tracked_outputs, - check=check, - executor=executor, - return_requested_outputs=return_requested_outputs - ) -end - -function run!( - ::ModelMapping{MultiScale}, - meteo=nothing, - constants=PlantMeteo.Constants(), - extra=nothing; - tracked_outputs=nothing, - check=true, - executor=ThreadedEx(), - return_requested_outputs=false, - requested_outputs_sink=DataFrames.DataFrame -) - error("This `ModelMapping` is a multiscale mapping. ", "Use `run!(mtg, mapping, ...)` for multiscale mappings.") -end - -# User entry point, which uses traits to dispatch to the correct method. -# The traits are defined in table_traits.jl -# and define either TableAlike, TreeAlike or SingletonAlike objects. -function run!( - object, - meteo=nothing, - constants=PlantMeteo.Constants(), - extra=nothing; - tracked_outputs=nothing, - check=true, - executor=ThreadedEx(), - return_requested_outputs=false, - requested_outputs_sink=DataFrames.DataFrame -) - _validate_meteo_duration(meteo) - run!( - DataFormat(object), - object, - meteo, - constants, - extra; - tracked_outputs, - check, - executor, - return_requested_outputs, - requested_outputs_sink - ) -end - -function run!( - object::GraphSimulation, - meteo=nothing, - constants=PlantMeteo.Constants(), - extra=nothing; - tracked_outputs=nothing, - check=true, - executor=ThreadedEx(), - return_requested_outputs=false, - requested_outputs_sink=DataFrames.DataFrame -) - _validate_meteo_duration(meteo) - run!( - TreeAlike(), - object, - meteo, - constants, - extra; - tracked_outputs=tracked_outputs, - check=check, - executor=executor, - return_requested_outputs=return_requested_outputs, - requested_outputs_sink=requested_outputs_sink, - ) -end - -########################################################################################## -## ModelList (single-scale) simulations -########################################################################################## - -# 1- several ModelList objects and several time-steps -function run!( - ::TableAlike, - object::T, - meteo::TimeStepTable{A}, - constants=PlantMeteo.Constants(), - extra=nothing; - tracked_outputs=nothing, - check=true, - executor=ThreadedEx(), - return_requested_outputs=false, - requested_outputs_sink=DataFrames.DataFrame -) where {T<:Union{AbstractArray,AbstractDict},A} - if _all_modellists_collection(object) - Base.depwarn( - "`run!` with a collection of `ModelList` is deprecated. Use a collection of `ModelMapping` objects instead.", - :run! - ) - end - - tracked_outputs isa OutputRequest && error("`OutputRequest` is only supported for MTG multi-rate simulations.") - tracked_outputs isa AbstractVector{<:OutputRequest} && error("`OutputRequest` is only supported for MTG multi-rate simulations.") - return_requested_outputs && error("`return_requested_outputs=true` is only supported for MTG multi-rate simulations.") - - if executor != SequentialEx() - @warn string( - "Parallelisation over objects was removed, (but may be reintroduced in the future). Parallelisation will only occur over timesteps." - ) maxlog = 1 - end - - outputs_collection = isa(object, AbstractArray) ? [] : isnothing(tracked_outputs) ? Dict() : Dict{TimeStepTable{Status{typeof(tracked_outputs)}}} - - # Each object: - for obj in object - - if isa(object, AbstractArray) - push!(outputs_collection, run!(obj, meteo, constants, extra, tracked_outputs=tracked_outputs, check=check, executor=executor)) - else - outputs_collection[obj.first] = run!(obj.second, meteo, constants, extra, tracked_outputs=tracked_outputs, check=check, executor=executor) - end - - end - return outputs_collection -end - -# 2 - One object, one or multiple meteo time-step(s), with vectors provided in the status -# (meaning a single meteo timestep might be expanded to fit the status vector size) -function run!( - ::SingletonAlike, - object::T, - meteo=nothing, - constants=PlantMeteo.Constants(), - extra=nothing; - tracked_outputs=nothing, - check=true, - executor=ThreadedEx(), - return_requested_outputs=false, - requested_outputs_sink=DataFrames.DataFrame -) where {T<:ModelList} - Base.depwarn( - "`run!(::ModelList, ...)` is deprecated. Use `run!(ModelMapping(...), ...)` instead.", - :run! - ) - _run_modellist_singleton( - object, - meteo, - constants, - extra; - tracked_outputs=tracked_outputs, - check=check, - executor=executor, - return_requested_outputs=return_requested_outputs - ) -end - -function run!( - ::SingletonAlike, - object::T, - meteo=nothing, - constants=PlantMeteo.Constants(), - extra=nothing; - tracked_outputs=nothing, - check=true, - executor=ThreadedEx(), - return_requested_outputs=false, - requested_outputs_sink=DataFrames.DataFrame -) where {T<:ModelMapping{SingleScale}} - model_list = _modellist_from_model_mapping(object) - - _run_modellist_singleton( - model_list, - meteo, - constants, - extra; - tracked_outputs=tracked_outputs, - check=check, - executor=executor, - return_requested_outputs=return_requested_outputs - ) -end - -function _run_modellist_singleton( - object::ModelList, - meteo=nothing, - constants=PlantMeteo.Constants(), - extra=nothing; - tracked_outputs=nothing, - check=true, - executor=ThreadedEx(), - return_requested_outputs=false -) - - tracked_outputs isa OutputRequest && error("`OutputRequest` is only supported for MTG multi-rate simulations.") - tracked_outputs isa AbstractVector{<:OutputRequest} && error("`OutputRequest` is only supported for MTG multi-rate simulations.") - return_requested_outputs && error("`return_requested_outputs=true` is only supported for MTG multi-rate simulations.") - - meteo_adjusted = adjust_weather_timesteps_to_given_length(get_status_vector_max_length(object.status), meteo) - nsteps = get_nsteps(meteo_adjusted) - - dep_graph = dep!(object, nsteps) - - if check - # Check if the meteo data and the status have the same length (or length 1) - check_dimensions(object, meteo_adjusted) - - if length(dep_graph.not_found) > 0 - error( - "The following processes are missing to run the ModelList: ", - dep_graph.not_found - ) - end - end - - - if executor != SequentialEx() && nsteps > 1 - if !timestep_parallelizable(dep_graph) - is_ts_parallel = which_timestep_parallelizable(dep_graph) - mods_not_parallel = join([i.second.first for i in is_ts_parallel[findall(x -> x.second.second == false, is_ts_parallel)]], "; ") - - check && @warn string( - "A parallel executor was provided (`executor=$(executor)`) but some models cannot be run in parallel: $mods_not_parallel. ", - "The simulation will be run sequentially. Use `executor=SequentialEx()` to remove this warning." - ) maxlog = 1 - else - outputs_preallocated_mt = pre_allocate_outputs(object, tracked_outputs, nsteps; type_promotion=object.type_promotion, check=check) - local vars = length(outputs_preallocated_mt) > 0 ? keys(outputs_preallocated_mt[1]) : NamedTuple() - status_flattened_template, vector_variables_mt = flatten_status(object.status) - - # Computing time-steps in parallel: - @floop executor for i in 1:nsteps - @init begin - status_flattened = deepcopy(status_flattened_template) - roots = collect(dep_graph.roots) - end - meteo_i = meteo_adjusted[i] - set_variables_at_timestep!(status_flattened, status(object), vector_variables_mt, i) - for (process, node) in roots - run_node!(object, node, i, status_flattened, meteo_i, constants, extra) - end - for var in vars - setproperty!(outputs_preallocated_mt[i], var, status_flattened[var]) - end - end - return outputs_preallocated_mt - end - end - - outputs_preallocated = pre_allocate_outputs(object, tracked_outputs, nsteps; type_promotion=object.type_promotion, check=check) - status_flattened, vector_variables = flatten_status(status(object)) - - # Not parallelizable over time-steps, it means some values depend on the previous value. - # In this case we propagate the values of the variables from one time-step to the other, except for - # the variables the user provided for all time-steps. - roots = collect(dep_graph.roots) - - # this bit is necessary for DataFrameRow meteos, see XPalm tests - if nsteps == 1 - for (process, node) in roots - run_node!(object, node, 1, status_flattened, meteo_adjusted, constants, extra) - end - save_results!(status_flattened, outputs_preallocated, 1) - else - - for (i, meteo_i) in enumerate(meteo_adjusted) - for (process, node) in roots - run_node!(object, node, i, status_flattened, meteo_i, constants, extra) - end - save_results!(status_flattened, outputs_preallocated, i) - i + 1 <= nsteps && set_variables_at_timestep!(status_flattened, status(object), vector_variables, i + 1) - end - end - - return outputs_preallocated -end - -# 3- several objects and one meteo time-step -function run!( - ::TableAlike, - object::T, - meteo, - constants=PlantMeteo.Constants(), - extra=nothing; - tracked_outputs=nothing, - check=true, - executor=ThreadedEx(), - return_requested_outputs=false, - requested_outputs_sink=DataFrames.DataFrame -) where {T<:Union{AbstractArray,AbstractDict}} - if _all_modellists_collection(object) - Base.depwarn( - "`run!` with a collection of `ModelList` is deprecated. Use a collection of `ModelMapping` objects instead.", - :run! - ) - end - - tracked_outputs isa OutputRequest && error("`OutputRequest` is only supported for MTG multi-rate simulations.") - tracked_outputs isa AbstractVector{<:OutputRequest} && error("`OutputRequest` is only supported for MTG multi-rate simulations.") - return_requested_outputs && error("`return_requested_outputs=true` is only supported for MTG multi-rate simulations.") - runtime_objects = [_single_scale_runtime_object(obj) for obj in collect(values(object))] - dep_graphs = [dep(obj) for obj in runtime_objects] - #obj_parallelizable = all([object_parallelizable(graph) for graph in dep_graphs]) - - # Check if the simulation can be parallelized over objects: - if executor != SequentialEx() - @warn string( - "Parallelisation over objects was removed, (but may be reintroduced in the future). Parallelisation will only occur over timesteps." - ) maxlog = 1 - end - - # Each object: - for (i, obj) in enumerate(runtime_objects) - - if check - # Check if the meteo data and the status have the same length (or length 1) - check_dimensions(obj, meteo) - - if length(dep_graphs[i].not_found) > 0 - error( - "The following processes are missing to run the ModelList: ", - dep_graphs[i].not_found - ) - end - end - end - - outputs_collection = isa(object, AbstractArray) ? [] : isnothing(tracked_outputs) ? Dict() : Dict{TimeStepTable{Status{typeof(tracked_outputs)}}} - - # Each object: - for obj in object - if isa(object, AbstractArray) - push!(outputs_collection, run!(obj, meteo, constants, extra, tracked_outputs=tracked_outputs, check=check, executor=executor)) - else - outputs_collection[obj.first] = run!(obj.second, meteo, constants, extra, tracked_outputs=tracked_outputs, check=check, executor=executor) - end - - end - return outputs_collection -end - - - -# Not exposed to the user : -# for each dependency node in the graph (always one time-step, one object), actual workhorse -function run_node!( - object::T, - node::SoftDependencyNode, - i, # time-step to index into the dependency node (to know if the model has been called already) - st, - meteo, - constants, - extra -) where {T<:ModelList} - - # Check if all the parents have been called before the child: - if !AbstractTrees.isroot(node) && any([p.simulation_id[i] <= node.simulation_id[i] for p in node.parent]) - # If not, this node should be called via another parent - return nothing - end - - # Actual call to the model: - run!(node.value, object.models, st, meteo, constants, extra) - node.simulation_id[i] += 1 # increment the simulation id, to know if the model has been called already - - # Recursively visit the children (soft dependencies only, hard dependencies are handled by the model itself): - for child in node.children - #! check if we can run this safely in a @floop loop. I would say no, - #! because we are running a parallel computation above already, modifying the node.simulation_id, - #! which is not thread-safe. - run_node!(object, child, i, st, meteo, constants, extra) - end -end - - -########################################################################################## -### Multiscale simulations -########################################################################################## - -# Another user entry point -# If we pass an MTG and a mapping, then we use them to compute a GraphSimulation object -# that we then use with the generic run! entry point. -function _multirate_tracked_outputs(tracked_outputs) - if isnothing(tracked_outputs) - return nothing, OutputRequest[] - elseif tracked_outputs isa OutputRequest - return nothing, OutputRequest[tracked_outputs] - elseif tracked_outputs isa AbstractVector{<:OutputRequest} - return nothing, collect(tracked_outputs) - end - return tracked_outputs, OutputRequest[] -end - -function _active_dependency_processes(dep_graph::DependencyGraph) - active = Set{Tuple{Symbol,Symbol}}() - for node in traverse_dependency_graph(dep_graph, false) - push!(active, (node.scale, node.process)) - end - return active -end - -function run!( - object::MultiScaleTreeGraph.Node, - mapping::ModelMapping, - meteo=nothing, - constants=PlantMeteo.Constants(), - extra=nothing; - nsteps=nothing, - tracked_outputs=nothing, - type_promotion=_type_promotion(mapping), - check=true, - executor=ThreadedEx(), - return_requested_outputs=false, - requested_outputs_sink=DataFrames.DataFrame -) - _validate_meteo_duration(meteo) - effective_multirate = _effective_multirate(mapping, meteo) - isnothing(nsteps) && (nsteps = get_nsteps(meteo)) - meteo_adjusted = if effective_multirate && meteo isa TimeStepTable{<:Atmosphere} - # Keep TimeStepTable intact in MTG multi-rate runs so model-clock meteo - # sampling/aggregation can use PlantMeteo sampler APIs. - meteo - elseif DataFormat(meteo) == TableAlike() - nsteps == 1 ? Tables.rows(meteo)[1] : meteo - else - adjust_weather_timesteps_to_given_length(nsteps, meteo) - end - status_outputs, output_requests = _multirate_tracked_outputs(tracked_outputs) - !effective_multirate && !isempty(output_requests) && error("`OutputRequest` requires a multirate `ModelMapping`.") - return_requested_outputs && !effective_multirate && error("`return_requested_outputs=true` requires a multirate `ModelMapping`.") - - # NOTE : replace_mapping_status_vectors_with_generated_models is assumed to have already run if used - # otherwise there might be vector length conflicts with timesteps - sim = GraphSimulation(object, mapping, nsteps=nsteps, check=check, outputs=status_outputs, type_promotion=type_promotion) - result = run!( - sim, - meteo_adjusted, - constants, - extra; - check=check, - executor=executor, - tracked_outputs=output_requests, - return_requested_outputs=return_requested_outputs, - requested_outputs_sink=requested_outputs_sink - ) - - if return_requested_outputs - return result - end - - return outputs(sim) -end - -function run!( - object::MultiScaleTreeGraph.Node, - mapping::AbstractDict{Symbol,T} where {T}, - meteo=nothing, - constants=PlantMeteo.Constants(), - extra=nothing; - nsteps=nothing, - tracked_outputs=nothing, - type_promotion=nothing, - check=true, - executor=ThreadedEx(), - return_requested_outputs=false, - requested_outputs_sink=DataFrames.DataFrame -) - Base.depwarn( - "`run!(mtg, mapping::AbstractDict, ...)` is deprecated. Use `run!(mtg, ModelMapping(mapping), ...)` or construct `ModelMapping(...)` directly.", - :run! - ) - run!( - object, - ModelMapping(mapping; type_promotion=type_promotion), - meteo, - constants, - extra; - nsteps=nsteps, - tracked_outputs=tracked_outputs, - type_promotion=type_promotion, - check=check, - executor=executor, - return_requested_outputs=return_requested_outputs, - requested_outputs_sink=requested_outputs_sink - ) -end - -function run!( - ::TreeAlike, - object::GraphSimulation, - meteo, - constants=PlantMeteo.Constants(), - extra=nothing; - tracked_outputs=nothing, - check=true, - executor=ThreadedEx(), - return_requested_outputs=false, - requested_outputs_sink=DataFrames.DataFrame -) - - effective_multirate = _effective_multirate(object) - dep_graph = object.dependency_graph - models = get_models(object) - _validate_meteo_duration(meteo) - timeline = _timeline_context(meteo) - meteo_sampler = effective_multirate ? _prepare_meteo_sampler(meteo) : nothing - runtime_clock_rows = _runtime_clock_rows(object, timeline, dep_graph) - effective_executor = executor - # st = status(object) - _validate_meteo_derived_timestep_requirements!(runtime_clock_rows, timeline) - if effective_multirate - if executor != SequentialEx() - @warn string( - "Multi-rate MTG runs currently execute sequentially. ", - "Provided `executor=$(executor)` is ignored in this mode. ", - "Use `executor=SequentialEx()` to silence this warning." - ) maxlog = 1 - effective_executor = SequentialEx() - end - _warn_if_no_model_runs_at_base_timestep(runtime_clock_rows, timeline) - validate_canonical_publishers(object) - prepare_output_requests!(object, tracked_outputs, timeline) - configure_temporal_buffers!(object, timeline) - elseif return_requested_outputs - error("`return_requested_outputs=true` requires a multirate `ModelMapping`.") - end - - !isnothing(extra) && error("Extra parameters are not allowed for the simulation of an MTG (already used for statuses).") - - nsteps = get_nsteps(meteo) - - # if this function is called directly with an atmosphere, don't use the Rows interface - if nsteps == 1 - roots = collect(dep_graph.roots) - for (process_key, dependency_node) in roots - run_node_multiscale!(object, dependency_node, 1, models, meteo, constants, object, check, effective_executor, effective_multirate, timeline, meteo_sampler) - end - effective_multirate && update_requested_outputs!(object, _time_from_step(1, timeline)) - save_results!(object, 1) - else - for (i, meteo_i) in enumerate(Tables.rows(meteo)) - roots = collect(dep_graph.roots) - for (process_key, dependency_node) in roots - run_node_multiscale!(object, dependency_node, i, models, meteo_i, constants, object, check, effective_executor, effective_multirate, timeline, meteo_sampler) - end - effective_multirate && update_requested_outputs!(object, _time_from_step(i, timeline)) - # At the end of the time-step, we save the results of the simulation in the object: - save_results!(object, i) - end - end - - # save_results! resizes the outputs melodramatically because the total # of nodes at a given scale can't always be known - # if models create organs, so shrink it down to the final size here - for (organ, index) in object.outputs_index - resize!(outputs(object)[organ], index - 1) - end - - if return_requested_outputs - return outputs(object), collect_outputs(object; sink=requested_outputs_sink) - end - - return outputs(object) -end - - -# Function that runs on dependency graph nodes, actual workhorse : -function run_node_multiscale!( - object::T, - node::SoftDependencyNode, - i, # time-step to index into the dependency node (to know if the model has been called already) - models, - meteo, - constants, - extra::T, # we pass the simulation object as extra so we can access its parameters during simulation - check, - executor, - multirate, - timeline::TimelineContext, - meteo_sampler -) where {T<:GraphSimulation} # T is the status of each node by organ type - - # run!(status(object), dependency_node, meteo, constants, extra) - # Check if all the parents have been called before the child: - if !AbstractTrees.isroot(node) && any([p.simulation_id[1] <= node.simulation_id[1] for p in node.parent]) - # If not, this node should be called via another parent - return nothing - end - - node_statuses = status(object)[node.scale] # Get the status of the nodes at the current scale - models_at_scale = models[node.scale] - model_specs_at_scale = get_model_specs(object)[node.scale] - model_spec = get(model_specs_at_scale, node.process, as_model_spec(node.value)) - model_clock = _model_clock(model_spec, node.value, timeline) - t = _time_from_step(i, timeline) - - for st in node_statuses # for each node status at the current scale (potentially in parallel over nodes) - should_run = !multirate || _should_run_at_time(model_clock, t) - !should_run && continue - if multirate - resolve_inputs_from_temporal_state!(object, node, st, t, model_spec, timeline) - end - meteo_for_model = multirate ? _sample_meteo_for_model(meteo_sampler, meteo, i, model_clock, model_spec) : meteo - # Actual call to the model: - run!(node.value, models_at_scale, st, meteo_for_model, constants, extra) - if multirate - update_temporal_state_outputs!(object, node, model_spec, st, t) - end - end - - node.simulation_id[1] += 1 # increment the simulation id, to remember that the model has been called already - - # Recursively visit the children (soft dependencies only, hard dependencies are handled by the model itself): - for child in node.children - #! check if we can run this safely in a @floop loop. I would say no, - #! because we are running a parallel computation above already, modifying the node.simulation_id, - #! which is not thread-safe yet. - run_node_multiscale!(object, child, i, models, meteo, constants, extra, check, executor, multirate, timeline, meteo_sampler) - end -end diff --git a/src/time/multirate.jl b/src/time/multirate.jl index 1def05682..b6e5b6d75 100644 --- a/src/time/multirate.jl +++ b/src/time/multirate.jl @@ -1,29 +1,7 @@ """ - ScopeId(kind, id) + ClockSpec(dt, phase=0) -Identifier for a simulation scope (e.g. global scene or plant). -""" -struct ScopeId - kind::Symbol - id::Int -end - -""" - ClockSpec(dt, phase) - -Clock definition for a model/process. - -# Details - -`dt` is the execution interval and `phase` is the offset of the execution grid. -In the current runtime, simulation steps are indexed as `t = 1, 2, 3, ...` (1-based). -A model runs when `t` is aligned with its clock. - -# Examples - -With `dt=24`: -- `ClockSpec(24.0, 1.0)` runs at `t = 1, 25, 49, ...` -- `ClockSpec(24.0, 0.0)` runs at `t = 24, 48, 72, ...` +Execution interval and phase, expressed in simulation steps. """ struct ClockSpec{T<:Real} dt::T @@ -32,81 +10,34 @@ end ClockSpec(dt::T) where {T<:Real} = ClockSpec{T}(dt, zero(T)) -""" - ModelKey(scope, scale, process) - -Unique key for one model process in one scope and scale. -""" -struct ModelKey - scope::ScopeId - scale::Symbol - process::Symbol -end - -""" - OutputKey(scope, scale, node_id, process, var) - -Unique key for one producer output stream. -""" -struct OutputKey - scope::ScopeId - scale::Symbol - node_id::Int - process::Symbol - var::Symbol -end - abstract type SchedulePolicy end -""" - HoldLast() - -Use the latest available producer value. -""" +"""Use the latest available producer value.""" struct HoldLast <: SchedulePolicy end function _as_schedule_policy(policy; context::AbstractString="schedule policy") if policy isa DataType policy <: SchedulePolicy || error( - "Unsupported $(context) type `$(policy)`. ", - "Expected a `SchedulePolicy` type or instance." + "Unsupported $(context) type `$(policy)`. Expected a SchedulePolicy type or instance." ) return try policy() catch - error( - "Unsupported $(context) type `$(policy)`: ", - "this policy type cannot be instantiated without arguments. ", - "Provide a policy instance instead." - ) + error("Schedule policy type `$(policy)` requires constructor arguments.") end elseif policy isa SchedulePolicy return policy end - - error( - "Unsupported $(context) value `$(policy)` of type `$(typeof(policy))`. ", - "Expected a `SchedulePolicy` type or instance." - ) + error("Unsupported $(context) value `$(policy)` of type `$(typeof(policy))`.") end const _INTERPOLATE_MODES = (:linear, :hold) """ - Interpolate() - Interpolate(mode) - Interpolate(mode, extrapolation) Interpolate(; mode=:linear, extrapolation=:linear) -Interpolation policy for fast consumers reading slower producer streams. - -Supported modes: -- `:linear`: linear interpolation between bracket points for real values -- `:hold`: left-hold (previous sample) - -Supported extrapolation modes when no future sample exists: -- `:linear`: linear extrapolation from last two samples when possible -- `:hold`: keep the latest sample +Interpolate a slower producer stream. Both `mode` and `extrapolation` accept +`:linear` or `:hold`. """ struct Interpolate{M<:Symbol,E<:Symbol} <: SchedulePolicy mode::M @@ -114,28 +45,22 @@ struct Interpolate{M<:Symbol,E<:Symbol} <: SchedulePolicy end Interpolate(mode::Symbol) = Interpolate(mode, :linear) -Interpolate(; mode::Symbol=:linear, extrapolation::Symbol=:linear) = Interpolate(mode, extrapolation) - -""" - Integrate() - Integrate(reducer) - -Windowed policy for consumers running at coarser clocks. -Values in the consumer window are reduced with `reducer`. - -Intended meaning: integrate/accumulate quantities over a window (for example -hourly flux to daily total). Default reducer is `SumReducer()`. +Interpolate(; mode::Symbol=:linear, extrapolation::Symbol=:linear) = + Interpolate(mode, extrapolation) -Important: `Integrate(r)` and `Aggregate(r)` are runtime-equivalent when they -use the same reducer `r`; they only differ by default reducer and naming intent. +function _normalize_policy_reducer(reducer) + if reducer isa DataType + reducer <: PlantMeteo.AbstractTimeReducer || error( + "Unsupported reducer type `$(reducer)`." + ) + return reducer() + elseif reducer isa PlantMeteo.AbstractTimeReducer || reducer isa Function + return reducer + end + error("Unsupported reducer value `$(reducer)` of type `$(typeof(reducer))`.") +end -Built-in reducers can be shared with meteo sampling from `PlantMeteo`: -`SumReducer()`, `MeanReducer()`, `MaxReducer()`, `MinReducer()`, `FirstReducer()`, -`LastReducer()`. -You can also provide a callable taking either: -- `values` -- `values, durations_seconds` -""" +"""Reduce a producer window, using a sum by default.""" struct Integrate{R} <: SchedulePolicy reducer::R function Integrate(reducer) @@ -144,26 +69,9 @@ struct Integrate{R} <: SchedulePolicy end end -""" - Aggregate() - Aggregate(reducer) - -Windowed aggregation policy for consumers running at coarser clocks. -Values in the consumer window are reduced with `reducer`. - -Intended meaning: summarize window values as a statistic (for example mean/max). -Default reducer is `MeanReducer()`. - -Important: `Aggregate(r)` and `Integrate(r)` are runtime-equivalent when they -use the same reducer `r`; they only differ by default reducer and naming intent. +Integrate() = Integrate(PlantMeteo.SumReducer()) -Built-in reducers can be shared with meteo sampling from `PlantMeteo`: -`SumReducer()`, `MeanReducer()`, `MaxReducer()`, `MinReducer()`, `FirstReducer()`, -`LastReducer()`. -You can also provide a callable taking either: -- `values` -- `values, durations_seconds` -""" +"""Reduce a producer window, using a mean by default.""" struct Aggregate{R} <: SchedulePolicy reducer::R function Aggregate(reducer) @@ -172,113 +80,29 @@ struct Aggregate{R} <: SchedulePolicy end end -function _normalize_policy_reducer(reducer) - if reducer isa DataType - reducer <: PlantMeteo.AbstractTimeReducer || error( - "Unsupported reducer type `$(reducer)`. ", - "Use a PlantMeteo reducer type/instance or a callable." - ) - return reducer() - elseif reducer isa PlantMeteo.AbstractTimeReducer - return reducer - elseif reducer isa Function - return reducer - end - - error( - "Unsupported reducer value `$(reducer)` of type `$(typeof(reducer))`. ", - "Use a PlantMeteo reducer type/instance or a callable." - ) -end - -Integrate() = Integrate(PlantMeteo.SumReducer()) - Aggregate() = Aggregate(PlantMeteo.MeanReducer()) -abstract type OutputCache end - -mutable struct HoldLastCache{T} <: OutputCache - t::Float64 - v::T -end - -mutable struct InterpolateCache{T} <: OutputCache - t_prev::Float64 - v_prev::T - t_curr::Float64 - v_curr::T -end - -mutable struct IntegrateCache{T<:Real} <: OutputCache - t_prev::Float64 - v_prev::T - acc::T - window_start::Float64 -end - -mutable struct AggregateCache{T<:Real} <: OutputCache - acc::T - n::Int - window_start::Float64 -end - -""" - ExportBuffer() - -Compact in-memory storage for requested output rows during runtime. -""" -mutable struct ExportBuffer{ - P<:Symbol, - V<:Symbol, - TI<:AbstractVector{Int}, - NI<:AbstractVector{Int}, - VV<:AbstractVector{Any}, -} - scale::Symbol - process::P - var::V - timestep::TI - node::NI - value::VV -end - -ExportBuffer(scale::Symbol, process::Symbol, var::Symbol) = ExportBuffer(scale, process, var, Int[], Int[], Any[]) - -""" - TemporalState(caches, last_run, streams, producer_horizons, export_plans, export_rows) - TemporalState() - -Temporal storage for multi-rate simulations. -`caches` stores producer hold-last outputs. -`last_run` stores last execution time per model key. -`streams` stores bounded producer `(time, value)` samples used for windowed and -interpolated policies. -`producer_horizons` stores required retention horizon per producer -`(scale, process, var)`. -`export_plans` stores resolved online export requests prepared before the run. -`export_rows` stores online-exported rows keyed by request name. -""" -mutable struct TemporalState{ - C<:AbstractDict{OutputKey,OutputCache}, - L<:AbstractDict{ModelKey,Float64}, - S<:AbstractDict{OutputKey,Vector{Tuple{Float64,Any}}}, - H<:AbstractDict{Tuple{Symbol,Symbol,Symbol},Float64}, - P<:AbstractVector, - R<:AbstractDict{Symbol,ExportBuffer} -} - caches::C - last_run::L - streams::S - producer_horizons::H - export_plans::P - export_rows::R -end - -TemporalState() = TemporalState( - Dict{OutputKey,OutputCache}(), - Dict{ModelKey,Float64}(), - Dict{OutputKey,Vector{Tuple{Float64,Any}}}(), - Dict{Tuple{Symbol,Symbol,Symbol},Float64}(), - Any[], - Dict{Symbol,ExportBuffer}() +function _validate_policy_instance( + scale::Symbol, + process::Symbol, + input::Symbol, + policy::SchedulePolicy, ) + if policy isa Interpolate + policy.mode in _INTERPOLATE_MODES || error( + "Invalid interpolation mode `$(policy.mode)` for $(scale)/$(process).$(input)." + ) + policy.extrapolation in _INTERPOLATE_MODES || error( + "Invalid extrapolation mode `$(policy.extrapolation)` for $(scale)/$(process).$(input)." + ) + elseif policy isa Union{Integrate,Aggregate} + reducer = policy.reducer + values = [1.0, 2.0] + durations = [1.0, 1.0] + applicable(reducer, values) || applicable(reducer, values, durations) || + error( + "Reducer for $(scale)/$(process).$(input) must accept values or values and durations." + ) + end + return nothing +end diff --git a/src/time/runtime/bindings.jl b/src/time/runtime/bindings.jl deleted file mode 100644 index 25507bada..000000000 --- a/src/time/runtime/bindings.jl +++ /dev/null @@ -1,111 +0,0 @@ -""" - _symbol_from_dependency_var(x) - -Extract a symbol variable name from dependency graph variable descriptors -(`Symbol`, `PreviousTimeStep`, `MappedVar`). -""" -function _symbol_from_dependency_var(x) - if x isa Symbol - return x - elseif x isa PreviousTimeStep - return x.variable - elseif x isa MappedVar - mv = mapped_variable(x) - return mv isa PreviousTimeStep ? mv.variable : mv - else - return nothing - end -end - -""" - _push_candidate_producer!(candidates, process_key, vars, input_var) - -Append producer candidates `(process, input_var)` when `vars` contains -`input_var`. -""" -function _push_candidate_producer!(candidates::Vector{Tuple{Symbol,Symbol}}, process_key, vars, input_var::Symbol) - process = Symbol(process_key) - for v in vars - s = _symbol_from_dependency_var(v) - isnothing(s) && continue - if s == input_var - push!(candidates, (process, input_var)) - end - end -end - -""" - _collect_candidate_producers!(candidates, parent_vars, input_var) - -Recursively collect candidate producers from nested dependency metadata. -""" -function _collect_candidate_producers!(candidates::Vector{Tuple{Symbol,Symbol}}, parent_vars::NamedTuple, input_var::Symbol) - for (k, v) in pairs(parent_vars) - if v isa NamedTuple - _collect_candidate_producers!(candidates, v, input_var) - elseif v isa Tuple || v isa AbstractVector - _push_candidate_producer!(candidates, k, v, input_var) - end - end -end - -""" - _candidate_producers(node, input_var) - -Return unique `(process, var)` producer candidates for `input_var` from the -soft-dependency parents of `node`. -""" -function _candidate_producers(node::SoftDependencyNode, input_var::Symbol) - node.parent_vars === nothing && return Tuple{Symbol,Symbol}[] - c = Tuple{Symbol,Symbol}[] - _collect_candidate_producers!(c, node.parent_vars, input_var) - unique(c) -end - -""" - _parse_input_binding(binding) - -Normalize one `InputBindings` entry to `(process, var, scale, policy)`. -Accepts shorthand forms (`Symbol`, `Pair{Symbol,Symbol}`, `NamedTuple`). -""" -function _parse_input_binding(binding) - if binding isa Symbol - return (process=binding, var=nothing, scale=nothing, policy=HoldLast()) - elseif binding isa Pair{Symbol,Symbol} - return (process=first(binding), var=last(binding), scale=nothing, policy=HoldLast()) - elseif binding isa NamedTuple - process = haskey(binding, :process) ? binding.process : nothing - var = haskey(binding, :var) ? binding.var : nothing - scale = if haskey(binding, :scale) - sc = binding.scale - isnothing(sc) ? nothing : - (sc isa AbstractString ? _normalize_scale(sc; warn=true, context=:ModelSpec) : sc) - else - nothing - end - policy = haskey(binding, :policy) ? binding.policy : HoldLast() - if policy isa DataType && policy <: SchedulePolicy - policy = policy() - end - return (process=process, var=var, scale=scale, policy=policy) - else - return nothing - end -end - -""" - _source_scale_for_process(node, process) - -Resolve the scale of a producer process from the current node parent links. -Falls back to the current node scale when no explicit parent match exists. -""" -function _source_scale_for_process(node::SoftDependencyNode, process::Symbol) - if node.parent !== nothing - for p in node.parent - if p.process == process - return p.scale - end - end - end - return node.scale -end diff --git a/src/time/runtime/clocks.jl b/src/time/runtime/clocks.jl index 47c7feff8..630ed601d 100644 --- a/src/time/runtime/clocks.jl +++ b/src/time/runtime/clocks.jl @@ -1,408 +1,246 @@ -""" - TimelineContext(base_step_seconds) - -Internal timing context for one simulation run. -`base_step_seconds` is the duration of one simulation step in seconds. -""" +"""Internal timing context for one model run.""" struct TimelineContext base_step_seconds::Float64 end -""" - _time_from_step(i, timeline) - -Convert a 1-based integer timestep index to the floating runtime time. -""" _time_from_step(i, ::TimelineContext) = float(i) -function _period_to_seconds(p::Dates.Period) - p isa Dates.FixedPeriod || error( - "Unsupported non-fixed period `$(typeof(p))` in timestep specification. ", - "Use fixed periods such as `Second`, `Minute`, `Hour`, `Day`." +timestep_hint(model::AbstractModel) = timestep_hint(typeof(model)) +timestep_hint(::Type{<:AbstractModel}) = nothing + +meteo_hint(model::AbstractModel) = meteo_hint(typeof(model)) +meteo_hint(::Type{<:AbstractModel}) = nothing + +struct _ResolvedTimeStepHint + fixed::Union{Nothing,Dates.FixedPeriod} + range::Union{Nothing,Tuple{Dates.FixedPeriod,Dates.FixedPeriod}} + preferred::Union{Nothing,Symbol,Dates.FixedPeriod} +end + +_seconds_from_period(period::Dates.FixedPeriod) = + float(Dates.value(Dates.Millisecond(period))) * 1.0e-3 + +function _normalize_required_timestep_hint(scale::Symbol, process::Symbol, required) + if required isa Dates.FixedPeriod + _seconds_from_period(required) > 0.0 || + error("Invalid timestep_hint for $(scale)/$(process): period must be positive.") + return required, nothing + elseif required isa Tuple && length(required) == 2 + lower, upper = required + lower isa Dates.FixedPeriod && upper isa Dates.FixedPeriod || error( + "Invalid timestep_hint for $(scale)/$(process): expected two fixed periods." + ) + lower_seconds = _seconds_from_period(lower) + upper_seconds = _seconds_from_period(upper) + 0.0 < lower_seconds <= upper_seconds || error( + "Invalid timestep_hint range for $(scale)/$(process)." + ) + return nothing, (lower, upper) + end + error( + "Invalid timestep_hint for $(scale)/$(process): expected a fixed period or period range." + ) +end + +function _normalize_timestep_hint(scale::Symbol, process::Symbol, hint) + isnothing(hint) && return _ResolvedTimeStepHint(nothing, nothing, nothing) + if hint isa Dates.FixedPeriod || hint isa Tuple + fixed, range = _normalize_required_timestep_hint(scale, process, hint) + return _ResolvedTimeStepHint(fixed, range, nothing) + elseif hint isa NamedTuple + haskey(hint, :required) || error( + "Invalid timestep_hint for $(scale)/$(process): `required` is mandatory." + ) + all(key -> key in (:required, :preferred), keys(hint)) || error( + "Invalid timestep_hint fields for $(scale)/$(process)." + ) + fixed, range = + _normalize_required_timestep_hint(scale, process, hint.required) + preferred = get(hint, :preferred, nothing) + if preferred isa Symbol + preferred in (:finest, :coarsest) || error( + "Invalid timestep_hint preference for $(scale)/$(process)." + ) + elseif !(isnothing(preferred) || preferred isa Dates.FixedPeriod) + error("Invalid timestep_hint preference for $(scale)/$(process).") + end + return _ResolvedTimeStepHint(fixed, range, preferred) + end + error("Invalid timestep_hint for $(scale)/$(process).") +end + +function _normalize_meteo_hint(scale::Symbol, process::Symbol, hint) + isnothing(hint) && return (bindings=nothing, window=nothing) + hint isa NamedTuple || error( + "Invalid meteo_hint for $(scale)/$(process): expected a NamedTuple." + ) + all(key -> key in (:bindings, :window), keys(hint)) || error( + "Invalid meteo_hint fields for $(scale)/$(process)." ) - sec = float(Dates.value(Dates.Second(p))) - sec > 0.0 || error("Invalid duration `$(p)`: expected a strictly positive period.") - return sec + bindings = + haskey(hint, :bindings) ? _normalize_meteo_bindings(hint.bindings) : nothing + window = haskey(hint, :window) ? _normalize_meteo_window(hint.window) : nothing + return (bindings=bindings, window=window) end -function _duration_to_seconds(d) - if d isa Dates.CompoundPeriod - periods = Dates.periods(d) - isempty(periods) && error("Unsupported empty `Dates.CompoundPeriod` in meteo duration.") - sec = sum(_period_to_seconds(p) for p in periods) - sec > 0.0 || error("Invalid meteo duration `$(d)`: expected a strictly positive value.") - return sec - elseif d isa Dates.Period - return _period_to_seconds(d) - elseif d isa Real - sec = float(d) - sec > 0.0 || error("Invalid meteo duration `$(d)`: expected a strictly positive value in seconds.") - return sec +function _period_to_seconds(period::Dates.Period) + period isa Dates.FixedPeriod || error( + "Unsupported non-fixed period `$(typeof(period))`. Use Second, Minute, Hour, or Day." + ) + seconds = float(Dates.value(Dates.Second(period))) + seconds > 0.0 || error("Expected a positive period, got `$(period)`.") + return seconds +end + +function _duration_to_seconds(duration) + if duration isa Dates.CompoundPeriod + periods = Dates.periods(duration) + isempty(periods) && error("Empty CompoundPeriod is not a valid duration.") + seconds = sum(_period_to_seconds(period) for period in periods) + seconds > 0.0 || error("Expected a positive duration, got `$(duration)`.") + return seconds + elseif duration isa Dates.Period + return _period_to_seconds(duration) + elseif duration isa Real + seconds = float(duration) + seconds > 0.0 || error("Expected a positive duration, got `$(duration)`.") + return seconds end return nothing end function _is_default_clock(clock::ClockSpec) - return isapprox(float(clock.dt), 1.0; atol=1.0e-9, rtol=0.0) && - isapprox(float(clock.phase), 0.0; atol=1.0e-9, rtol=0.0) + isapprox(float(clock.dt), 1.0; atol=1.0e-9, rtol=0.0) && + isapprox(float(clock.phase), 0.0; atol=1.0e-9, rtol=0.0) end -function _timestep_to_step_count(ts::Dates.Period, timeline::TimelineContext) - sec = _period_to_seconds(ts) - step = sec / timeline.base_step_seconds - step >= 1.0 || error( - "Model timestep `$(ts)` is shorter than simulation base step ($(timeline.base_step_seconds) seconds). ", - "This runtime does not support sub-step execution." +function _timestep_to_step_count(period::Dates.Period, timeline::TimelineContext) + steps = _period_to_seconds(period) / timeline.base_step_seconds + steps >= 1.0 || error( + "Model timestep `$(period)` is shorter than the simulation base step." ) - return step + return steps end -function _first_table_row(table; context::String="meteo") - rows = Tables.rows(table) - state = iterate(rows) - isnothing(state) && error( - "Cannot infer simulation timestep from empty $(context) table. ", - "Provide at least one row with a valid `duration`." - ) +function _first_table_row(table; context::String="meteorology") + state = iterate(Tables.rows(table)) + isnothing(state) && error("Cannot infer a timestep from an empty $(context) table.") return state[1] end -function _base_step_seconds_from_meteo_row(row; require_duration::Bool=false, context::String="meteo") +function _base_step_seconds_from_meteo_row( + row; + require_duration::Bool=false, + context::String="meteorology", +) if hasproperty(row, :duration) - d = getproperty(row, :duration) - sec = _duration_to_seconds(d) - !isnothing(sec) && return sec - require_duration && error( - "Invalid `duration=$(d)` (type `$(typeof(d))`) in $(context). ", - "Expected a positive Real (seconds), `Dates.Period`, or `Dates.CompoundPeriod`." - ) + duration = getproperty(row, :duration) + seconds = _duration_to_seconds(duration) + !isnothing(seconds) && return seconds + require_duration && error("Invalid duration `$(duration)` in $(context).") elseif require_duration - error( - "Missing required `duration` in $(context). ", - "Meteorology must define a valid per-row duration." - ) + error("Missing required `duration` in $(context).") end return 1.0 end function _validate_meteo_duration(meteo) isnothing(meteo) && return nothing - if meteo isa Atmosphere - _base_step_seconds_from_meteo_row(meteo; require_duration=true, context="meteo") - return nothing - end - - if meteo isa TimeStepTable || DataFormat(meteo) == TableAlike() + _base_step_seconds_from_meteo_row(meteo; require_duration=true) + elseif meteo isa TimeStepTable || DataFormat(meteo) == TableAlike() + base_seconds = nothing for (i, row) in enumerate(Tables.rows(meteo)) - _base_step_seconds_from_meteo_row(row; require_duration=true, context="meteo row $(i)") + seconds = _base_step_seconds_from_meteo_row( + row; + require_duration=true, + context="meteorology row $(i)", + ) + if isnothing(base_seconds) + base_seconds = seconds + elseif !isapprox(seconds, base_seconds; atol=1.0e-9, rtol=0.0) + error( + "Inconsistent `duration` in meteorology row $(i): ", + "$(seconds) seconds does not match the base step ", + "$(base_seconds) seconds from row 1. Composite model scheduling ", + "requires a fixed environment base step." + ) + end end - return nothing - end - - if DataFormat(meteo) == SingletonAlike() && hasproperty(meteo, :duration) - _base_step_seconds_from_meteo_row(meteo; require_duration=true, context="meteo") - return nothing + elseif DataFormat(meteo) == SingletonAlike() && hasproperty(meteo, :duration) + _base_step_seconds_from_meteo_row(meteo; require_duration=true) end - - # Unknown formats are validated later by run-path specific checks. return nothing end function _timeline_context(meteo) - if meteo isa TimeStepTable - row = _first_table_row(meteo; context="meteo") - return TimelineContext(_base_step_seconds_from_meteo_row(row; require_duration=true, context="meteo")) - elseif meteo isa Atmosphere - return TimelineContext(_base_step_seconds_from_meteo_row(meteo; require_duration=true, context="meteo")) - elseif !isnothing(meteo) && DataFormat(meteo) == TableAlike() - row = _first_table_row(meteo; context="meteo") - return TimelineContext(_base_step_seconds_from_meteo_row(row; require_duration=true, context="meteo")) - elseif !isnothing(meteo) && DataFormat(meteo) == SingletonAlike() && hasproperty(meteo, :duration) - return TimelineContext(_base_step_seconds_from_meteo_row(meteo; require_duration=true, context="meteo")) + if meteo isa TimeStepTable || (!isnothing(meteo) && DataFormat(meteo) == TableAlike()) + row = _first_table_row(meteo) + return TimelineContext( + _base_step_seconds_from_meteo_row(row; require_duration=true) + ) + elseif meteo isa Atmosphere || + (!isnothing(meteo) && DataFormat(meteo) == SingletonAlike() && + hasproperty(meteo, :duration)) + return TimelineContext( + _base_step_seconds_from_meteo_row(meteo; require_duration=true) + ) end return TimelineContext(1.0) end -""" - _clock_from_spec_timestep(ts, timeline) - -Normalize a `ModelSpec.timestep` value to a `ClockSpec` when possible. -Returns `nothing` when no explicit clock can be derived. -""" -function _clock_from_spec_timestep(ts, timeline::TimelineContext) - if ts isa ClockSpec - return ts - elseif ts isa Real - return ClockSpec(float(ts), 0.0) - elseif ts isa Dates.Period - return ClockSpec(_timestep_to_step_count(ts, timeline), 1.0) - else - return nothing - end +function _clock_from_spec_timestep(timestep, timeline::TimelineContext) + timestep isa ClockSpec && return timestep + timestep isa Real && return ClockSpec(float(timestep), 0.0) + timestep isa Dates.Period && + return ClockSpec(_timestep_to_step_count(timestep, timeline), 1.0) + return nothing end -""" - _model_clock(model_spec, model) - -Return the effective execution clock for `model`, using user-provided -`ModelSpec` timestep override when available, otherwise `timespec(model)`. -""" function _model_clock(model_spec, model, timeline::TimelineContext) - spec_ts = isnothing(model_spec) ? nothing : PlantSimEngine.timestep(model_spec) - c = _clock_from_spec_timestep(spec_ts, timeline) - !isnothing(c) && return c - - model_clock = timespec(model) - _is_default_clock(model_clock) && return ClockSpec(1.0, 0.0) - return model_clock -end - -""" - _should_run_at_time(clock, t) - -Decide whether a model with `clock` should execute at simulation time `t`. -""" -function _should_run_at_time(clock::ClockSpec, t::Float64) - dt = float(clock.dt) - phase = float(clock.phase) - dt <= 0 && error("Invalid model clock: `dt` must be > 0, got $(dt).") - dt <= 1.0 && return true - # Robust phase alignment check for floating clocks. - return isapprox(mod(t - phase, dt), 0.0; atol=1e-8, rtol=0.0) -end - -""" - _window_start_for_clock(clock, t) - -Return the left bound of the consumer window `[t_start, t]` used by windowed -policies (`Integrate`, `Aggregate`) for a given consumer clock. -""" -function _window_start_for_clock(clock::ClockSpec, t::Float64) - dt = float(clock.dt) - dt <= 1.0 && return t - return t - dt + 1.0 -end - -_effective_multirate(mapping::ModelMapping) = is_multirate(mapping) -_effective_multirate(mapping::ModelMapping, meteo) = is_multirate(mapping) -_effective_multirate(sim::GraphSimulation) = is_multirate(sim) - -function _format_seconds_label(sec::Float64) - ms = round(Int, sec * 1000.0) - if ms % 3_600_000 == 0 - return string(Dates.Hour(ms ÷ 3_600_000)) - elseif ms % 60_000 == 0 - return string(Dates.Minute(ms ÷ 60_000)) - elseif ms % 1000 == 0 - return string(Dates.Second(ms ÷ 1000)) - end - return string(Dates.Millisecond(ms)) -end - -struct EffectiveRateSummary - base_step_seconds::Float64 - rates::Dict{Float64,Vector{NamedTuple}} + selected = isnothing(model_spec) ? nothing : timestep(model_spec) + clock = _clock_from_spec_timestep(selected, timeline) + !isnothing(clock) && return clock + declared = timespec(model) + return _is_default_clock(declared) ? ClockSpec(1.0, 0.0) : declared end -function Base.show(io::IO, ::MIME"text/plain", summary::EffectiveRateSummary) - println(io, "Effective model rates (base step: ", _format_seconds_label(summary.base_step_seconds), ")") - for rate_sec in sort!(collect(keys(summary.rates))) - entries = summary.rates[rate_sec] - println(io, " - ", _format_seconds_label(rate_sec), " (", length(entries), " model(s))") - for row in sort!(collect(entries); by=x -> (string(x.scale), string(x.process))) - println(io, " • ", row.scale, "/", row.process, " [", row.source, "]") - end - end -end - -""" - effective_rate_summary(mapping::ModelMapping{MultiScale}, meteo) - -Summarize the effective model execution rates implied by `mapping` and `meteo`. -Returns an `EffectiveRateSummary` struct with details on the effective rates and their sources. - -To get the value of the base step used for the simulation, use `summary.base_step_seconds`. -To get the effective rates and their sources, use `summary.rates`, which is a dictionary -mapping each effective rate (in seconds) to a vector of named tuples with keys `:scale`, -`:process`, `:source`, and `:model` describing the models running at that rate and their -source of timing. -""" -function effective_rate_summary(mapping::ModelMapping{MultiScale}, meteo) - _validate_meteo_duration(meteo) - timeline = _timeline_context(meteo) - rows = _runtime_clock_rows(mapping, timeline) - grouped = Dict{Float64,Vector{NamedTuple}}() - - for row in rows - rate_sec = float(row.clock.dt) * timeline.base_step_seconds - entry = (scale=row.scale, process=row.process, source=row.source, model=typeof(row.model)) - push!(get!(grouped, rate_sec, NamedTuple[]), entry) - end - - return EffectiveRateSummary(timeline.base_step_seconds, grouped) +function _runtime_clock_source_for_spec(spec::ModelSpec) + !isnothing(timestep(spec)) && return :modelspec + return _is_default_clock(timespec(model_(spec))) ? :meteo_base_step : + :model_timespec end -function _resolve_meteo_hint_clock(scale::Symbol, process::Symbol, model, timeline::TimelineContext) - base_sec = timeline.base_step_seconds +function _resolve_meteo_hint_clock( + scale::Symbol, + process::Symbol, + model, + timeline::TimelineContext, +) + base_seconds = timeline.base_step_seconds hint = _normalize_timestep_hint(scale, process, timestep_hint(model)) - desired_sec = base_sec reason = nothing - if !isnothing(hint.fixed) - required_sec = _seconds_from_period(hint.fixed) - if !isapprox(required_sec, base_sec; atol=1.0e-9, rtol=0.0) - reason = string( - "Meteo base step ", - _format_seconds_label(base_sec), - " is outside `timestep_hint.required=", - hint.fixed, - "` for `", - scale, - "/", - process, - "`." - ) - end + required = _seconds_from_period(hint.fixed) + isapprox(required, base_seconds; atol=1.0e-9, rtol=0.0) || + (reason = "Meteorology base step is outside `timestep_hint.required=$(hint.fixed)` for `$(scale)/$(process)`.") elseif !isnothing(hint.range) - lo, hi = hint.range - lo_sec = _seconds_from_period(lo) - hi_sec = _seconds_from_period(hi) - if base_sec < lo_sec || base_sec > hi_sec - reason = string( - "Meteo base step ", - _format_seconds_label(base_sec), - " is outside `timestep_hint.required=(", - lo, - ", ", - hi, - ")` for `", - scale, - "/", - process, - "`." - ) - end - end - - dt = desired_sec / base_sec - return ClockSpec(dt, 0.0), reason -end - -function _runtime_clock_rows(mapping::ModelMapping{MultiScale}, timeline::TimelineContext) - specs_by_scale = !isempty(mapping.info.model_specs) ? - mapping.info.model_specs : - Dict(scale => parse_model_specs(scale_mapping) for (scale, scale_mapping) in pairs(mapping)) - - rows = NamedTuple[] - for (scale, specs_at_scale) in pairs(specs_by_scale) - for (process, spec) in pairs(specs_at_scale) - model = model_(spec) - source = _runtime_clock_source_for_spec(spec) - clock = _model_clock(spec, model, timeline) - hint_reason = nothing - if source == :meteo_base_step - clock, hint_reason = _resolve_meteo_hint_clock(scale, process, model, timeline) - end - push!(rows, ( - scale=scale, - process=process, - source=source, - clock=clock, - model=model, - hint_reason=hint_reason, - )) - end - end - - return rows -end - -function _mapping_requires_runtime_multirate(mapping::ModelMapping{MultiScale}, meteo) - isnothing(meteo) && return false - timeline = _timeline_context(meteo) - rows = _runtime_clock_rows(mapping, timeline) - return any(!isapprox(float(row.clock.dt), 1.0; atol=1.0e-9, rtol=0.0) for row in rows) -end - -function _effective_multirate(mapping::ModelMapping{MultiScale}, meteo) - is_multirate(mapping) && return true - return _mapping_requires_runtime_multirate(mapping, meteo) -end - - - -function _runtime_clock_source_for_spec(spec::ModelSpec) - !isnothing(timestep(spec)) && return :modelspec - return _is_default_clock(timespec(model_(spec))) ? :meteo_base_step : :model_timespec -end - -function _runtime_clock_rows(object::GraphSimulation, timeline::TimelineContext, dep_graph::DependencyGraph) - active = _active_dependency_processes(dep_graph) - rows = NamedTuple[] - - for (scale, specs_at_scale) in pairs(get_model_specs(object)) - for (process, spec) in pairs(specs_at_scale) - (scale, process) in active || continue - model = model_(spec) - source = _runtime_clock_source_for_spec(spec) - clock = _model_clock(spec, model, timeline) - hint_reason = nothing - if source == :meteo_base_step - clock, hint_reason = _resolve_meteo_hint_clock(scale, process, model, timeline) - end - push!(rows, ( - scale=scale, - process=process, - source=source, - clock=clock, - model=model, - hint_reason=hint_reason, - )) - end + lower, upper = _seconds_from_period.(hint.range) + lower <= base_seconds <= upper || + (reason = "Meteorology base step is outside `timestep_hint.required=$(hint.range)` for `$(scale)/$(process)`.") end - - return rows + return ClockSpec(1.0, 0.0), reason end - -function _warn_if_no_model_runs_at_base_timestep(rows, timeline::TimelineContext) - isempty(rows) && return nothing - any(isapprox(float(row.clock.dt), 1.0; atol=1.0e-9, rtol=0.0) for row in rows) && return nothing - - source_label(source) = source == :modelspec ? "ModelSpec" : (source == :model_timespec ? "timespec(model)" : "meteo") - details = sort([ - string( - row.scale, - "/", - row.process, - "=", - round(float(row.clock.dt) * timeline.base_step_seconds; digits=6), - "s (", - source_label(row.source), - ")" - ) for row in rows - ]) - - @warn string( - "No model runs at the meteo base timestep (", - timeline.base_step_seconds, - " s). Resolved model timesteps: ", - join(details, ", "), - "." - ) maxlog = 1 - return nothing +function _should_run_at_time(clock::ClockSpec, time::Float64) + dt = float(clock.dt) + phase = float(clock.phase) + dt > 0.0 || error("Clock interval must be positive, got $(dt).") + dt <= 1.0 && return true + return isapprox(mod(time - phase, dt), 0.0; atol=1.0e-8, rtol=0.0) end - -function _validate_meteo_derived_timestep_requirements!(rows, timeline::TimelineContext) - for row in rows - row.source == :meteo_base_step || continue - - if !isnothing(row.hint_reason) - error(row.hint_reason) - end - end - - return nothing +function _window_start_for_clock(clock::ClockSpec, time::Float64) + dt = float(clock.dt) + return dt <= 1.0 ? time : time - dt + 1.0 end diff --git a/src/time/runtime/environment_backends.jl b/src/time/runtime/environment_backends.jl new file mode 100644 index 000000000..37f352dee --- /dev/null +++ b/src/time/runtime/environment_backends.jl @@ -0,0 +1,369 @@ +""" + AbstractEnvironmentBackend + +Backend protocol for meteorology and mutable microclimate providers. + +PlantSimEngine defines the protocol, not the spatial indexing strategy. External +packages can subtype this and implement `sample`, `scatter!`, `update_index!`, +`get_nsteps`, and `base_step_seconds`. +""" +abstract type AbstractEnvironmentBackend end + +""" + EnvironmentSupport(application, scale, process, status) + +Minimal support descriptor passed to environment backends when a model samples +or scatters environmental variables. +""" +struct EnvironmentSupport{S} + application::Symbol + scale::Symbol + process::Symbol + status::S +end + +""" + GlobalConstant(meteo) + +Environment backend that preserves the current PlantSimEngine behavior: every +model receives the same meteo object or meteo row at a given timestep. +""" +struct GlobalConstant{M} <: AbstractEnvironmentBackend + meteo::M +end + +environment_meteo(backend::GlobalConstant) = backend.meteo + +""" + environment_backend(meteo_or_backend) + +Return an environment backend. Plain meteorology is wrapped in +`GlobalConstant`; existing environment backends are returned unchanged. +""" +environment_backend(backend::AbstractEnvironmentBackend) = backend +environment_backend(meteo) = GlobalConstant(meteo) + +""" + base_step_seconds(backend) + +Return the duration of one simulation base step in seconds. +""" +function base_step_seconds(backend::AbstractEnvironmentBackend) + error( + "Environment backend `$(typeof(backend))` must implement ", + "`PlantSimEngine.base_step_seconds(backend)`." + ) +end + +function base_step_seconds(backend::GlobalConstant) + return _timeline_context(environment_meteo(backend)).base_step_seconds +end + +get_nsteps(backend::AbstractEnvironmentBackend) = error( + "Environment backend `$(typeof(backend))` must implement ", + "`PlantSimEngine.get_nsteps(backend)`." +) +get_nsteps(backend::GlobalConstant) = isnothing(environment_meteo(backend)) ? 1 : get_nsteps(environment_meteo(backend)) + +function _validate_meteo_duration(backend::AbstractEnvironmentBackend) + sec = base_step_seconds(backend) + sec isa Real && sec > 0 || error( + "Environment backend `$(typeof(backend))` returned invalid base step seconds `$(sec)`." + ) + return nothing +end + +_validate_meteo_duration(backend::GlobalConstant) = _validate_meteo_duration(environment_meteo(backend)) + +_timeline_context(backend::AbstractEnvironmentBackend) = TimelineContext(float(base_step_seconds(backend))) +_timeline_context(backend::GlobalConstant) = _timeline_context(environment_meteo(backend)) + +function _meteo_row_at_step(meteo, i::Int) + isnothing(meteo) && return nothing + if meteo isa TimeStepTable || DataFormat(meteo) == TableAlike() + return first(Iterators.drop(Tables.rows(meteo), i - 1)) + end + return meteo +end + +function _available_meteo_variables(meteo) + row = _first_meteo_row(meteo) + isnothing(row) && return nothing + return Set(Symbol.(propertynames(row))) +end + +""" + environment_variables(backend) + +Return a set of variable names that the backend can provide, or `nothing` when +the backend cannot enumerate them cheaply. +""" +environment_variables(::AbstractEnvironmentBackend) = nothing +function environment_variables(backend::GlobalConstant) + meteo = environment_meteo(backend) + isnothing(meteo) && return Set{Symbol}() + return _available_meteo_variables(meteo) +end + +function validate_meteo_inputs(model_specs::Dict{Symbol,Dict{Symbol,ModelSpec}}, backend::GlobalConstant) + return invoke( + validate_meteo_inputs, + Tuple{Dict{Symbol,Dict{Symbol,ModelSpec}},AbstractEnvironmentBackend}, + model_specs, + backend, + ) +end + +function validate_meteo_inputs(model_specs::Dict{Symbol,Dict{Symbol,ModelSpec}}, backend::AbstractEnvironmentBackend) + available = environment_variables(backend) + isnothing(available) && return nothing + + missing_rows = _collect_missing_meteo_rows(model_specs, var -> var in available) + return _error_missing_meteo_inputs( + missing_rows; + subject="Environment backend `$(typeof(backend))`", + noun="variables", + target="the backend" + ) +end + +function _model_validation_scale(model::CompositeModel, application::CompiledModelApplication) + scales = Set{Symbol}() + for object_id in application.target_ids + object = _model_object(model, object_id) + isnothing(object.scale) || push!(scales, object.scale) + end + isempty(scales) && return :Scene + length(scales) == 1 && return only(scales) + return :Scene +end + +function _model_model_specs_by_application(compiled::CompiledCompositeModel) + specs = Dict{Symbol,Dict{Symbol,ModelSpec}}() + for application in compiled.applications + scale = _model_validation_scale(compiled.model, application) + specs_at_scale = get!(specs, scale, Dict{Symbol,ModelSpec}()) + specs_at_scale[application.id] = application.spec + end + return specs +end + +""" + validate_meteo_inputs(model::CompositeModel) + validate_meteo_inputs(compiled::CompiledCompositeModel) + validate_meteo_inputs(compiled::CompiledCompositeModel, meteo_or_backend) + +Validate declared composite-model/object `meteo_inputs_`. + +The one-argument methods validate each application against its actual compiled +environment backend, including application-level `Environment(...)` overrides. +The two-argument method validates every compiled application against an +explicit replacement meteorology/environment backend. Diagnostics report model +application ids, so several applications for the same process can be diagnosed +independently. +""" +function validate_meteo_inputs(compiled::CompiledCompositeModel, meteo_or_backend) + backend = environment_backend(meteo_or_backend) + return validate_meteo_inputs(_model_model_specs_by_application(compiled), backend) +end + +function validate_meteo_inputs(compiled::CompiledCompositeModel) + refresh_environment_bindings!(compiled.model, compiled) + return nothing +end + +function validate_meteo_inputs(model::CompositeModel) + compiled = refresh_bindings!(model) + return validate_meteo_inputs(compiled) +end + +function validate_meteo_inputs(model::CompositeModel, meteo_or_backend) + compiled = refresh_bindings!(model) + return validate_meteo_inputs(compiled, meteo_or_backend) +end + +""" + sample(backend, variable, support, time) + +Sample one environmental variable for a model support at a runtime time. +""" +function sample(backend::AbstractEnvironmentBackend, variable::Symbol, support::EnvironmentSupport, time) + error( + "Environment backend `$(typeof(backend))` must implement ", + "`PlantSimEngine.sample(backend, variable, support, time)`." + ) +end + +function sample(backend::GlobalConstant, variable::Symbol, support::EnvironmentSupport, time) + meteo = _meteo_row_at_step(environment_meteo(backend), Int(round(time))) + isnothing(meteo) && return nothing + hasproperty(meteo, variable) || error( + "GlobalConstant meteo does not provide variable `$(variable)` for `$(support.application)/$(support.scale)/$(support.process)`." + ) + return getproperty(meteo, variable) +end + +""" + scatter!(backend, variable, support, value, time) + +Scatter one model-computed environmental value back to a mutable backend. +""" +function scatter!(backend::AbstractEnvironmentBackend, variable::Symbol, support::EnvironmentSupport, value, time) + error( + "Environment backend `$(typeof(backend))` does not implement ", + "`PlantSimEngine.scatter!(backend, variable, support, value, time)`." + ) +end + +scatter!(backend::GlobalConstant, variable::Symbol, support::EnvironmentSupport, value, time) = error( + "GlobalConstant is immutable and cannot receive environment output `$(variable)` from ", + "`$(support.application)/$(support.scale)/$(support.process)`." +) + +""" + update_index!(backend, entities) + +Update the backend spatial/entity index after topology or geometry changes. +""" +update_index!(::AbstractEnvironmentBackend, entities) = nothing +update_index!(::GlobalConstant, entities) = nothing + +""" + sample_environment(backend, support, time, variables) + +Sample a meteo-like row for a model. `GlobalConstant` returns the original meteo +row; other backends return a `NamedTuple` assembled from `sample` calls. +""" +function sample_environment(backend::AbstractEnvironmentBackend, support::EnvironmentSupport, time, variables) + pairs = Pair{Symbol,Any}[] + for variable in variables + push!(pairs, variable => sample(backend, variable, support, time)) + end + return (; pairs...) +end + +function sample_environment(backend::GlobalConstant, support::EnvironmentSupport, time, variables) + return _meteo_row_at_step(environment_meteo(backend), Int(round(time))) +end + +function _environment_sampling_rules(model_spec::ModelSpec) + bindings = meteo_bindings(model_spec) + bindings = bindings isa NamedTuple ? bindings : NamedTuple() + environment_sources = _environment_source_overrides(model_spec) + rules = Pair{Symbol,Symbol}[] + for target in keys(meteo_inputs_(model_spec)) + source = target + if haskey(bindings, target) + rule = bindings[target] + rule isa NamedTuple && haskey(rule, :source) && (source = Symbol(rule.source)) + end + if haskey(environment_sources, target) + source = Symbol(environment_sources[target]) + end + push!(rules, target => source) + end + return rules +end + +function _environment_source_overrides(model_spec::ModelSpec) + config = environment_config(model_spec) + payload = config isa EnvironmentConfig ? config.config : config + if payload isa NamedTuple && haskey(payload, :sources) + sources = payload.sources + sources isa NamedTuple || error( + "Environment `sources` must be a NamedTuple, for example ", + "`Environment(; sources=(CO2=:Ca,))`." + ) + return sources + end + return NamedTuple() +end + +function sample_environment( + backend::AbstractEnvironmentBackend, + support::EnvironmentSupport, + time, + model_spec::ModelSpec +) + pairs = Pair{Symbol,Any}[] + for (target, source) in _environment_sampling_rules(model_spec) + push!(pairs, target => sample(backend, source, support, time)) + end + return (; pairs...) +end + +function sample_environment( + backend::GlobalConstant, + support::EnvironmentSupport, + time, + model_spec::ModelSpec +) + row = _meteo_row_at_step(environment_meteo(backend), Int(round(time))) + bindings = meteo_bindings(model_spec) + has_bindings = bindings isa NamedTuple && !isempty(keys(bindings)) + environment_sources = _environment_source_overrides(model_spec) + has_environment_sources = !isempty(keys(environment_sources)) + !has_bindings && !has_environment_sources && return row + + pairs = Pair{Symbol,Any}[] + for (target, source) in _environment_sampling_rules(model_spec) + isnothing(row) && error( + "GlobalConstant meteo is `nothing`, but `$(support.application)/$(support.scale)/$(support.process)` ", + "requires meteo variable `$(target)`." + ) + hasproperty(row, source) || error( + "GlobalConstant meteo does not provide source variable `$(source)` for model-facing variable ", + "`$(target)` in `$(support.application)/$(support.scale)/$(support.process)`." + ) + push!(pairs, target => getproperty(row, source)) + end + if !isnothing(row) && hasproperty(row, :duration) + push!(pairs, :duration => getproperty(row, :duration)) + end + return (; pairs...) +end + +function _environment_output_value(status, variable::Symbol, support::EnvironmentSupport) + hasproperty(status, variable) && return getproperty(status, variable) + error( + "Model `$(support.application)/$(support.scale)/$(support.process)` declares environment output ", + "`$(variable)` in `meteo_outputs_`, but its status does not contain `$(variable)`. ", + "Expose the computed value as a same-named `outputs_` variable or initialize it in the status." + ) +end + +""" + scatter_environment_outputs!(backend, support, time, model_spec, status) + +Scatter values declared by `meteo_outputs_(model)` from model status into a +mutable environment backend. +""" +function scatter_environment_outputs!( + backend::AbstractEnvironmentBackend, + support::EnvironmentSupport, + time, + model_spec::ModelSpec, + status +) + for variable in keys(meteo_outputs_(model_spec)) + value = _environment_output_value(status, variable, support) + scatter!(backend, variable, support, value, time) + end + return nothing +end + +""" + explain_environment(simulation) + +Return a compact description of the environment backend used by a model +simulation. +""" +function explain_environment(simulation) + backend = simulation.environment + return ( + backend=typeof(backend), + variables=environment_variables(backend), + nsteps=get_nsteps(backend), + base_step_seconds=base_step_seconds(backend), + ) +end diff --git a/src/time/runtime/input_resolution.jl b/src/time/runtime/input_resolution.jl deleted file mode 100644 index 759a44d71..000000000 --- a/src/time/runtime/input_resolution.jl +++ /dev/null @@ -1,569 +0,0 @@ -""" - _resolved_value_for_source(sim, source_scope, source_scale, source_process, source_var, source_node_id, t) - -Resolve one producer value through hold-last cache lookup. -Returns `(value, found::Bool)`. -""" -function _resolved_value_for_source(sim::GraphSimulation, source_scope::ScopeId, source_scale::Symbol, source_process::Symbol, source_var::Symbol, source_node_id::Int, t::Float64) - key = OutputKey(source_scope, source_scale, source_node_id, source_process, source_var) - cache = get(sim.temporal_state.caches, key, nothing) - if cache isa HoldLastCache - return cache.v, true - end - return nothing, false -end - -_resolution_samples(sim::GraphSimulation, key::OutputKey) = get(sim.temporal_state.streams, key, nothing) - -""" - _resolved_windowed_value_for_source(sim, source_scope, source_scale, source_process, source_var, source_node_id, t_start, t_end, policy) - -Resolve one producer value over `[t_start, t_end]` for windowed policies. -Returns `(value, found::Bool)`. -""" -function _resolved_windowed_value_for_source( - sim::GraphSimulation, - source_scope::ScopeId, - source_scale::Symbol, - source_process::Symbol, - source_var::Symbol, - source_node_id::Int, - t_start::Float64, - t_end::Float64, - policy::SchedulePolicy, - source_sample_duration_seconds::Float64 -) - key = OutputKey(source_scope, source_scale, source_node_id, source_process, source_var) - samples = _resolution_samples(sim, key) - isnothing(samples) && return nothing, false - - if policy isa Union{Integrate,Aggregate} - vals_real = Float64[] - durations_real = Float64[] - for (ts, v) in samples - ts < t_start - 1e-8 && continue - ts > t_end + 1e-8 && continue - v isa Real || return nothing, false - push!(vals_real, float(v)) - push!(durations_real, source_sample_duration_seconds) - end - isempty(vals_real) && return nothing, false - return _window_reduce(vals_real, durations_real, policy), true - end - - return nothing, false -end - -""" - _resolved_interpolated_value_for_source(sim, source_scope, source_scale, source_process, source_var, source_node_id, t, policy) - -Resolve one producer value at time `t` using interpolation/extrapolation over -stored temporal samples. -Returns `(value, found::Bool)`. -""" -function _resolved_interpolated_value_for_source( - sim::GraphSimulation, - source_scope::ScopeId, - source_scale::Symbol, - source_process::Symbol, - source_var::Symbol, - source_node_id::Int, - t::Float64, - policy::Interpolate -) - key = OutputKey(source_scope, source_scale, source_node_id, source_process, source_var) - samples = _resolution_samples(sim, key) - isnothing(samples) && return nothing, false - isempty(samples) && return nothing, false - - prev_idx = findlast(s -> s[1] <= t + 1e-8, samples) - next_idx = findfirst(s -> s[1] >= t - 1e-8, samples) - - # Interpolate between known bracketing points when available. - if !isnothing(prev_idx) && !isnothing(next_idx) - t_prev, v_prev = samples[prev_idx] - t_next, v_next = samples[next_idx] - if isapprox(t_prev, t_next; atol=1e-8, rtol=0.0) - return v_prev, true - end - if policy.mode == :linear && v_prev isa Real && v_next isa Real - α = (t - t_prev) / (t_next - t_prev) - return v_prev + α * (v_next - v_prev), true - end - return v_prev, true - end - - # Real-time fallback when no future sample exists yet: - # use linear extrapolation from last two samples if possible, else hold-last. - if !isnothing(prev_idx) - t_last, v_last = samples[prev_idx] - if policy.extrapolation == :linear && prev_idx >= 2 - t_prev, v_prev = samples[prev_idx - 1] - if v_prev isa Real && v_last isa Real && !isapprox(t_last, t_prev; atol=1e-8, rtol=0.0) - α = (t - t_last) / (t_last - t_prev) - return v_last + α * (v_last - v_prev), true - end - end - return v_last, true - end - - # If only future data exists, use the earliest known value. - return samples[1][2], true -end - -function _resolve_window_reducer(reducer) - if reducer isa DataType - reducer <: PlantMeteo.AbstractTimeReducer || error( - "Unsupported reducer type `$(reducer)`. Use a PlantMeteo reducer type/instance or a callable." - ) - return reducer() - elseif reducer isa PlantMeteo.AbstractTimeReducer - return reducer - elseif reducer isa Function - return reducer - end - - error( - "Unsupported reducer value `$(reducer)` of type `$(typeof(reducer))`. ", - "Use a PlantMeteo reducer type/instance or a callable." - ) -end - -function _window_reduce(vals::AbstractVector{<:Real}, durations::AbstractVector{<:Real}, policy::SchedulePolicy) - reducer = policy isa Integrate ? policy.reducer : (policy isa Aggregate ? policy.reducer : PlantMeteo.SumReducer()) - f = _resolve_window_reducer(reducer) - - if applicable(f, vals, durations) - return f(vals, durations) - end - - applicable(f, vals) || error( - "Reducer `$(reducer)` is not callable on collected window values for policy `$(typeof(policy))`. ", - "Expected `(values)` or `(values, durations_seconds)`." - ) - - return f(vals) -end - -""" - _assign_input_value!(st, input_var, value) - -Assign an input value into `Status`, preserving in-place updates for `RefVector` -inputs when both sides are vectors. -""" -function _assign_input_value!(st::Status, input_var::Symbol, value) - current = st[input_var] - if current isa RefVector && value isa AbstractVector - length(current) != length(value) && resize!(current, length(value)) - for i in eachindex(value) - current[i] = value[i] - end - return nothing - end - st[input_var] = value - return nothing -end - -function _same_scale_status_value(source_statuses, target_node_id::Int, source_var::Symbol) - for src_st in source_statuses - node_id(src_st.node) == target_node_id || continue - source_var in keys(src_st) || continue - return src_st[source_var], true - end - return nothing, false -end - -function _ancestor_node_id_for_scale(node, source_scale::Symbol) - ancestor = parent(node) - while !isnothing(ancestor) - if symbol(ancestor) == source_scale - return node_id(ancestor) - end - ancestor = parent(ancestor) - end - return nothing -end - -function _status_for_node_id(source_statuses, target_node_id::Int) - for src_st in source_statuses - node_id(src_st.node) == target_node_id && return src_st - end - return nothing -end - -function _prefer_local_status_fallback(st::Status, input_var::Symbol, source_var::Symbol, prefer_local_status::Bool) - prefer_local_status || return false - source_var in keys(st) || return false - _assign_input_value!(st, input_var, st[source_var]) - return true -end - -""" - _resolve_input_windowed(sim, node, st, input_var, source_scale, source_process, source_var, t_start, t_end, policy) - -Resolve one consumer input from producer temporal streams using a windowed -policy (`Integrate` or `Aggregate`) and write it into `st`. -""" -function _resolve_input_windowed( - sim::GraphSimulation, - node::SoftDependencyNode, - st::Status, - consumer_scope::ScopeId, - source_model_spec, - prefer_local_status::Bool, - input_var::Symbol, - source_scale::Symbol, - source_process::Symbol, - source_var::Symbol, - t_start::Float64, - t_end::Float64, - policy::SchedulePolicy, - source_sample_duration_seconds::Float64 -) - source_statuses = get(status(sim), source_scale, nothing) - isnothing(source_statuses) && return nothing - - current_value = st[input_var] - if current_value isa AbstractVector - vals = Any[] - for src_st in source_statuses - src_node_id = node_id(src_st.node) - source_scope = _scope_for_status(sim, source_model_spec, source_scale, source_process, src_st.node) - source_scope == consumer_scope || continue - v, ok = _resolved_windowed_value_for_source( - sim, source_scope, source_scale, source_process, source_var, src_node_id, t_start, t_end, policy, source_sample_duration_seconds - ) - if ok - push!(vals, v) - elseif policy isa Integrate || policy isa Aggregate - push!(vals, 0.0) - elseif source_var in keys(src_st) - push!(vals, src_st[source_var]) - end - end - length(vals) > 0 && _assign_input_value!(st, input_var, vals) - return nothing - end - - _prefer_local_status_fallback(st, input_var, source_var, prefer_local_status) && return nothing - - consumer_node_id = node_id(st.node) - v, ok = _resolved_windowed_value_for_source( - sim, consumer_scope, source_scale, source_process, source_var, consumer_node_id, t_start, t_end, policy, source_sample_duration_seconds - ) - if ok - _assign_input_value!(st, input_var, v) - return nothing - end - - # Same-scale scalar fallback: prefer the value attached to the consumer node - # before scanning all source nodes (which can be ambiguous in dense scales). - if source_scale == node.scale - vv, found = _same_scale_status_value(source_statuses, consumer_node_id, source_var) - if found - _assign_input_value!(st, input_var, vv) - return nothing - end - else - ancestor_node_id = _ancestor_node_id_for_scale(st.node, source_scale) - if !isnothing(ancestor_node_id) - src_st = _status_for_node_id(source_statuses, ancestor_node_id) - if !isnothing(src_st) - source_scope = _scope_for_status(sim, source_model_spec, source_scale, source_process, src_st.node) - if source_scope == consumer_scope - vv, found = _resolved_windowed_value_for_source( - sim, source_scope, source_scale, source_process, source_var, ancestor_node_id, t_start, t_end, policy, source_sample_duration_seconds - ) - if found - _assign_input_value!(st, input_var, vv) - return nothing - elseif source_var in keys(src_st) - _assign_input_value!(st, input_var, src_st[source_var]) - return nothing - end - end - end - end - end - - # Cross-scale scalar fallback: allow unique producer value at source scale. - candidates = Any[] - for src_st in source_statuses - src_node_id = node_id(src_st.node) - source_scope = _scope_for_status(sim, source_model_spec, source_scale, source_process, src_st.node) - source_scope == consumer_scope || continue - vv, found = _resolved_windowed_value_for_source( - sim, source_scope, source_scale, source_process, source_var, src_node_id, t_start, t_end, policy, source_sample_duration_seconds - ) - found && push!(candidates, vv) - end - if length(candidates) == 1 - _assign_input_value!(st, input_var, only(candidates)) - elseif length(candidates) > 1 - error( - "Ambiguous cross-scale source values for input `$(input_var)` in process `$(node.process)` at scale `$(node.scale)`. ", - "Please provide `InputBindings(...)` with explicit `scale`/source disambiguation." - ) - elseif policy isa Integrate || policy isa Aggregate - _assign_input_value!(st, input_var, 0.0) - end - - return nothing -end - -""" - _resolve_input_interpolate(sim, node, st, input_var, source_scale, source_process, source_var, t, policy) - -Resolve one consumer input from producer temporal streams using interpolation -policy and write it into `st`. -""" -function _resolve_input_interpolate( - sim::GraphSimulation, - node::SoftDependencyNode, - st::Status, - consumer_scope::ScopeId, - source_model_spec, - prefer_local_status::Bool, - input_var::Symbol, - source_scale::Symbol, - source_process::Symbol, - source_var::Symbol, - t::Float64, - policy::Interpolate -) - source_statuses = get(status(sim), source_scale, nothing) - isnothing(source_statuses) && return nothing - - current_value = st[input_var] - if current_value isa AbstractVector - vals = Any[] - for src_st in source_statuses - src_node_id = node_id(src_st.node) - source_scope = _scope_for_status(sim, source_model_spec, source_scale, source_process, src_st.node) - source_scope == consumer_scope || continue - v, ok = _resolved_interpolated_value_for_source( - sim, source_scope, source_scale, source_process, source_var, src_node_id, t, policy - ) - if ok - push!(vals, v) - elseif source_var in keys(src_st) - push!(vals, src_st[source_var]) - end - end - length(vals) > 0 && _assign_input_value!(st, input_var, vals) - return nothing - end - - _prefer_local_status_fallback(st, input_var, source_var, prefer_local_status) && return nothing - - consumer_node_id = node_id(st.node) - v, ok = _resolved_interpolated_value_for_source( - sim, consumer_scope, source_scale, source_process, source_var, consumer_node_id, t, policy - ) - if ok - _assign_input_value!(st, input_var, v) - return nothing - end - - # Cross-scale scalar fallback: allow unique producer value at source scale. - candidates = Any[] - for src_st in source_statuses - src_node_id = node_id(src_st.node) - source_scope = _scope_for_status(sim, source_model_spec, source_scale, source_process, src_st.node) - source_scope == consumer_scope || continue - vv, found = _resolved_interpolated_value_for_source( - sim, source_scope, source_scale, source_process, source_var, src_node_id, t, policy - ) - found && push!(candidates, vv) - end - if length(candidates) == 1 - _assign_input_value!(st, input_var, only(candidates)) - elseif length(candidates) > 1 - error( - "Ambiguous cross-scale source values for input `$(input_var)` in process `$(node.process)` at scale `$(node.scale)`. ", - "Please provide `InputBindings(...)` with explicit `scale`/source disambiguation." - ) - end - - return nothing -end - -""" - _resolve_input_holdlast(sim, node, st, input_var, source_scale, source_process, source_var, t) - -Resolve one consumer input from producer hold-last values and write it into -`st`. -""" -function _resolve_input_holdlast( - sim::GraphSimulation, - node::SoftDependencyNode, - st::Status, - consumer_scope::ScopeId, - source_model_spec, - prefer_local_status::Bool, - input_var::Symbol, - source_scale::Symbol, - source_process::Symbol, - source_var::Symbol, - t::Float64 -) - source_statuses = get(status(sim), source_scale, nothing) - isnothing(source_statuses) && return nothing - - current_value = st[input_var] - if current_value isa AbstractVector - vals = Any[] - for src_st in source_statuses - src_node_id = node_id(src_st.node) - source_scope = _scope_for_status(sim, source_model_spec, source_scale, source_process, src_st.node) - source_scope == consumer_scope || continue - v, ok = _resolved_value_for_source(sim, source_scope, source_scale, source_process, source_var, src_node_id, t) - if ok - push!(vals, v) - else - if source_var in keys(src_st) - push!(vals, src_st[source_var]) - end - end - end - length(vals) > 0 && _assign_input_value!(st, input_var, vals) - return nothing - end - - _prefer_local_status_fallback(st, input_var, source_var, prefer_local_status) && return nothing - - consumer_node_id = node_id(st.node) - v, ok = _resolved_value_for_source(sim, consumer_scope, source_scale, source_process, source_var, consumer_node_id, t) - if ok - _assign_input_value!(st, input_var, v) - return nothing - end - - # Same-scale scalar fallback: prefer the value attached to the consumer node - # before scanning all source nodes (which can be ambiguous in dense scales). - if source_scale == node.scale - vv, found = _same_scale_status_value(source_statuses, consumer_node_id, source_var) - if found - _assign_input_value!(st, input_var, vv) - return nothing - end - else - ancestor_node_id = _ancestor_node_id_for_scale(st.node, source_scale) - if !isnothing(ancestor_node_id) - src_st = _status_for_node_id(source_statuses, ancestor_node_id) - if !isnothing(src_st) - source_scope = _scope_for_status(sim, source_model_spec, source_scale, source_process, src_st.node) - if source_scope == consumer_scope - vv, found = _resolved_value_for_source(sim, source_scope, source_scale, source_process, source_var, ancestor_node_id, t) - if found - _assign_input_value!(st, input_var, vv) - return nothing - elseif source_var in keys(src_st) - _assign_input_value!(st, input_var, src_st[source_var]) - return nothing - end - end - end - end - end - - # Cross-scale scalar fallback: allow unique producer value at source scale. - candidates = Any[] - for src_st in source_statuses - src_node_id = node_id(src_st.node) - source_scope = _scope_for_status(sim, source_model_spec, source_scale, source_process, src_st.node) - source_scope == consumer_scope || continue - vv, found = _resolved_value_for_source(sim, source_scope, source_scale, source_process, source_var, src_node_id, t) - found && push!(candidates, vv) - end - if length(candidates) == 1 - _assign_input_value!(st, input_var, only(candidates)) - elseif length(candidates) > 1 - error( - "Ambiguous cross-scale source values for input `$(input_var)` in process `$(node.process)` at scale `$(node.scale)`. ", - "Please provide `InputBindings(...)` with explicit `scale`/source disambiguation." - ) - end - - return nothing -end - -""" - resolve_inputs_from_temporal_state!(sim, node, st, t, model_spec, timeline) - -Resolve all model inputs for `node` at time `t` using declared -`InputBindings(...)` and schedule policies, then mutate `st` in place. -""" -function resolve_inputs_from_temporal_state!(sim::GraphSimulation, node::SoftDependencyNode, st::Status, t::Float64, model_spec, timeline::TimelineContext) - model = node.value - ins = keys(inputs_(model)) - length(ins) == 0 && return nothing - consumer_clock = _model_clock(model_spec, model, timeline) - t_start = _window_start_for_clock(consumer_clock, t) - consumer_scope = _scope_for_status(sim, model_spec, node.scale, node.process, st.node) - - bindings = input_bindings(model_spec) - - for input_var in ins - binding = input_var in keys(bindings) ? _parse_input_binding(bindings[input_var]) : nothing - - source_process = nothing - source_var = input_var - policy = HoldLast() - policy_is_explicit = false - - if !isnothing(binding) && !isnothing(binding.process) - source_process = binding.process - source_var = isnothing(binding.var) ? input_var : binding.var - source_scale = isnothing(binding.scale) ? _source_scale_for_process(node, source_process) : binding.scale - policy = binding.policy - policy_is_explicit = true - else - candidates = _candidate_producers(node, input_var) - if length(candidates) == 1 - source_process, source_var = only(candidates) - source_scale = _source_scale_for_process(node, source_process) - elseif length(candidates) > 1 - error( - "Ambiguous producer for input `$(input_var)` in process `$(node.process)` at scale `$(node.scale)`. ", - "Please define explicit `InputBindings(...)` for this model in your mapping." - ) - else - continue - end - end - prefer_local_status = !policy_is_explicit - source_model_spec = _model_spec_for_process(sim, source_scale, source_process) - source_model = get_models(sim)[source_scale][source_process] - source_clock = _model_clock(source_model_spec, source_model, timeline) - source_sample_duration_seconds = float(source_clock.dt) * timeline.base_step_seconds - if !policy_is_explicit - policy = _policy_for_output(model_(source_model_spec), source_var) - end - - if policy isa HoldLast - _resolve_input_holdlast(sim, node, st, consumer_scope, source_model_spec, prefer_local_status, input_var, source_scale, source_process, source_var, t) - elseif policy isa Interpolate - _resolve_input_interpolate(sim, node, st, consumer_scope, source_model_spec, prefer_local_status, input_var, source_scale, source_process, source_var, t, policy) - elseif policy isa Integrate || policy isa Aggregate - _resolve_input_windowed( - sim, - node, - st, - consumer_scope, - source_model_spec, - prefer_local_status, - input_var, - source_scale, - source_process, - source_var, - t_start, - t, - policy, - source_sample_duration_seconds - ) - end - end - - return nothing -end diff --git a/src/time/runtime/meteo_sampling.jl b/src/time/runtime/meteo_sampling.jl index 8a80f62bf..c99ba7e6d 100644 --- a/src/time/runtime/meteo_sampling.jl +++ b/src/time/runtime/meteo_sampling.jl @@ -11,22 +11,7 @@ function _prepare_meteo_sampler(meteo) end function _runtime_meteo_window(window) - if isnothing(window) - return nothing - elseif window isa PlantMeteo.AbstractSamplingWindow - return window - elseif window isa DataType - window <: PlantMeteo.AbstractSamplingWindow || error( - "Unsupported MeteoWindow type `$(window)`. ", - "Use a PlantMeteo sampling-window type/instance." - ) - return window() - end - - error( - "Unsupported MeteoWindow value `$(window)` of type `$(typeof(window))`. ", - "Use a PlantMeteo sampling-window type/instance." - ) + return _normalize_meteo_window(window) end function _meteo_sampling_window(clock::ClockSpec, model_spec) @@ -42,24 +27,7 @@ function _meteo_sampling_window(clock::ClockSpec, model_spec) return window end -function _normalize_meteo_reducer(reducer) - if reducer isa DataType - reducer <: PlantMeteo.AbstractTimeReducer || error( - "Unsupported meteo reducer type `$(reducer)`. ", - "Use a PlantMeteo reducer type/instance or a callable." - ) - return reducer() - elseif reducer isa PlantMeteo.AbstractTimeReducer - return reducer - elseif reducer isa Function - return reducer - end - - error( - "Unsupported meteo reducer value `$(reducer)` of type `$(typeof(reducer))`. ", - "Use a PlantMeteo reducer type/instance or a callable." - ) -end +_normalize_meteo_reducer(reducer) = _normalize_policy_reducer(reducer) function _normalize_meteo_binding_rule(target::Symbol, rule) if rule isa NamedTuple @@ -76,6 +44,130 @@ function _normalize_meteo_binding_rule(target::Symbol, rule) ) end +function _raw_meteo_requirements_for_spec(model_spec) + required = Set{Symbol}(keys(meteo_inputs_(model_spec))) + isempty(required) && return required + + raw_required = Set{Symbol}() + bindings = meteo_bindings(model_spec) + bindings = bindings isa NamedTuple ? bindings : NamedTuple() + for var in required + if haskey(bindings, var) + rule = bindings[var] + if rule isa NamedTuple && haskey(rule, :source) + push!(raw_required, Symbol(rule.source)) + else + push!(raw_required, var) + end + else + push!(raw_required, var) + end + end + + return raw_required +end + +function _first_meteo_row(meteo) + isnothing(meteo) && return nothing + is_table = try + DataFormat(meteo) == TableAlike() + catch + false + end + if is_table + rows = Tables.rows(meteo) + state = iterate(rows) + isnothing(state) && return nothing + return state[1] + end + return meteo +end + +_meteo_has_field(row, var::Symbol) = hasproperty(row, var) +_meteo_has_field(row::NamedTuple, var::Symbol) = haskey(row, var) + +function _collect_missing_meteo_rows(model_specs::Dict{Symbol,Dict{Symbol,ModelSpec}}, has_meteo_variable) + missing_rows = NamedTuple[] + for (scale, specs_at_scale) in model_specs + for (process, spec) in specs_at_scale + required = _raw_meteo_requirements_for_spec(spec) + missing = Symbol[var for var in required if !has_meteo_variable(var)] + isempty(missing) && continue + push!(missing_rows, (scale=scale, process=process, missing=Tuple(missing))) + end + end + return missing_rows +end + +function _format_missing_meteo_rows(missing_rows) + return join( + [ + string(row.scale, "/", row.process, " missing ", row.missing) + for row in missing_rows + ], + "; " + ) +end + +function _error_missing_meteo_inputs(missing_rows; subject::AbstractString, noun::AbstractString, target::AbstractString) + isempty(missing_rows) && return nothing + + error( + subject, + " is missing ", + noun, + " required by model `meteo_inputs_`: ", + _format_missing_meteo_rows(missing_rows), + ". Add the ", + noun, + " to ", + target, + ", declare an `Environment(; sources=...)` remapping, ", + "or remove the unused meteo input from the model trait." + ) +end + +""" + validate_meteo_inputs(model_specs, meteo) + +Validate declared `meteo_inputs_` against the available meteorological fields. + +The check is intentionally field-based and independent from units/backends. When +Environment source bindings remap a declared model input from another variable; the +source variable is checked on the raw meteo object. +""" +function validate_meteo_inputs(model_specs::Dict{Symbol,Dict{Symbol,ModelSpec}}, meteo) + row = _first_meteo_row(meteo) + isnothing(row) && return nothing + + missing_rows = _collect_missing_meteo_rows(model_specs, var -> _meteo_has_field(row, var)) + return _error_missing_meteo_inputs( + missing_rows; + subject="Meteorology", + noun="fields", + target="meteo" + ) +end + +function validate_meteo_inputs(model_specs::AbstractDict{Symbol,<:AbstractDict}, meteo) + normalized_specs = Dict{Symbol,Dict{Symbol,ModelSpec}}() + for (scale, specs_at_scale) in pairs(model_specs) + normalized_specs[scale] = Dict{Symbol,ModelSpec}( + Symbol(process) => as_model_spec(spec) for (process, spec) in pairs(specs_at_scale) + ) + end + return validate_meteo_inputs(normalized_specs, meteo) +end + +function validate_meteo_inputs(models::NamedTuple, meteo) + specs = Dict( + :Default => Dict{Symbol,ModelSpec}( + process(model) => as_model_spec(model) for model in values(models) + ) + ) + return validate_meteo_inputs(specs, meteo) +end + function _meteo_transforms_for_model(model_spec) bindings = meteo_bindings(model_spec) isnothing(bindings) && return nothing @@ -102,7 +194,7 @@ function _sample_meteo_for_model( isnothing(meteo_sampler) && begin if !isnothing(transforms) || !isnothing(window) @warn string( - "MeteoBindings or MeteoWindow were provided but weather sampler API is unavailable or meteo is not TimeStepTable{Atmosphere}. ", + "Environment sampling rules were provided but the weather sampler API is unavailable or meteorology is not TimeStepTable{Atmosphere}. ", "Falling back to raw meteo rows." ) maxlog = 1 end diff --git a/src/time/runtime/output_export.jl b/src/time/runtime/output_export.jl index 1565a83db..5ecb4f929 100644 --- a/src/time/runtime/output_export.jl +++ b/src/time/runtime/output_export.jl @@ -1,349 +1,63 @@ """ - OutputRequest(scale, var; name=var, process=nothing, policy=HoldLast(), clock=nothing) + OutputRequest(selector, var; name=var, application=nothing, context=nothing, + policy=HoldLast(), clock=nothing) + OutputRequest(scale, var; kwargs...) -Describe one online-exported multi-rate output series for MTG multi-rate runs. +Request retention and optional resampling of one model output stream. -Use this type in `run!(...; tracked_outputs=...)` to export -resampled temporal streams while simulation is running. - -# Arguments -- `scale::Symbol`: producer scale (for example `:Leaf` or `:Plant`). -- `var::Symbol`: source variable name published on `scale`. - -# Keyword arguments -- `name::Symbol=var`: name of the exported series in `collect_outputs(sim)` or - returned output dictionaries. Names must be unique across requests. -- `process=nothing`: producer process name (`Symbol`/`String`) or `nothing`. - When `nothing`, runtime tries to use the unique canonical publisher for - `(scale, var)` and errors on ambiguity. -- `policy::SchedulePolicy=HoldLast()`: resampling policy applied at export time. - Common values are `HoldLast()`, `Integrate(...)`, `Aggregate(...)`, - `Interpolate(...)`. - `Integrate` and `Aggregate` are runtime-equivalent with the same reducer; - they differ by default reducer (`SumReducer` vs `MeanReducer`) and intent. -- `clock=nothing`: export clock. When `nothing`, export is evaluated at each - simulation step (`ClockSpec(1.0, 0.0)`). Accepted explicit values are the same - as model timestep specs (`Real`, `ClockSpec`, or fixed `Dates.Period`). - -# Example -```julia -req_daily = OutputRequest( - :Leaf, - :A; - name=:A_daily, - process=:toyassim, - policy=Integrate(), - clock=ClockSpec(24.0, 0.0), -) -``` +The first form accepts the same `One`, `OptionalOne`, or `Many` selector used +by `AppliesTo`, `Inputs`, `Calls`, and object queries. Passing a scale is a +convenience for `Many(scale=scale)`. """ -struct OutputRequest{P<:Union{Nothing,Symbol},POL<:SchedulePolicy,C} - scale::Symbol +struct OutputRequest{S<:AbstractObjectMultiplicity,P<:Union{Nothing,Symbol},A<:Union{Nothing,Symbol},CT,POL<:SchedulePolicy,C} + selector::S var::Symbol name::Symbol process::P + application::A + context::CT policy::POL clock::C end function OutputRequest( - scale::Symbol, + selector::AbstractObjectMultiplicity, var::Symbol; name::Symbol=var, process=nothing, + application=nothing, + context=nothing, policy::SchedulePolicy=HoldLast(), - clock=nothing + clock=nothing, ) + if !isnothing(process) + Base.depwarn( + "`process=` in `OutputRequest` is deprecated; name the model application and use `application=`.", + :OutputRequest, + ) + end proc = isnothing(process) ? nothing : Symbol(process) - return OutputRequest(scale, var, name, proc, policy, clock) + app = isnothing(application) ? nothing : Symbol(application) + return OutputRequest(selector, var, name, proc, app, context, policy, clock) end -function OutputRequest( - scale::AbstractString, - var::Symbol; - name::Symbol=var, - process=nothing, - policy::SchedulePolicy=HoldLast(), - clock=nothing -) - return OutputRequest( - _normalize_scale(scale; warn=true, context=:OutputRequest), - var; - name=name, - process=process, - policy=policy, - clock=clock - ) -end +OutputRequest(scale::Union{Symbol,AbstractString}, var::Symbol; kwargs...) = + OutputRequest(Many(scale=Symbol(scale)), var; kwargs...) function _export_clock(request::OutputRequest, timeline::TimelineContext) isnothing(request.clock) && return ClockSpec(1.0, 0.0) - c = _clock_from_spec_timestep(request.clock, timeline) - isnothing(c) && error( + clock = _clock_from_spec_timestep(request.clock, timeline) + isnothing(clock) && error( "Unsupported clock specification `$(typeof(request.clock))` in OutputRequest `$(request.name)`." ) - return c -end - -function _canonical_source_process(sim::GraphSimulation, scale::Symbol, var::Symbol) - haskey(get_models(sim), scale) || error("Unknown scale `$(scale)` in output export request.") - models_at_scale = get_models(sim)[scale] - specs_at_scale = get_model_specs(sim)[scale] - ignored_same_rate_hard_children = _same_rate_hard_dependency_children(get_model_specs(sim), dep(sim)) - ignored_at_scale = get(ignored_same_rate_hard_children, scale, Set{Symbol}()) - - publishers = Symbol[] - for (process, model) in pairs(models_at_scale) - process in ignored_at_scale && continue - var in keys(outputs_(model)) || continue - spec = get(specs_at_scale, process, as_model_spec(model)) - _publish_mode_for_output(spec, var) == :stream_only && continue - push!(publishers, process) - end - - if isempty(publishers) - error( - "No canonical publisher found for variable `$(var)` at scale `$(scale)`. ", - "Provide `process=` in OutputRequest or mark one producer as canonical." - ) - elseif length(publishers) > 1 - error( - "Ambiguous canonical publishers for variable `$(var)` at scale `$(scale)`: ", - join(publishers, ", "), - ". Provide `process=` in OutputRequest." - ) - end - - return only(publishers) + return clock end function _normalize_output_requests(requests) isnothing(requests) && return OutputRequest[] requests isa OutputRequest && return OutputRequest[requests] requests isa AbstractVector{<:OutputRequest} || error( - "`tracked_outputs` (for multi-rate exports) must be `nothing`, an `OutputRequest`, or a vector of `OutputRequest`." + "`outputs` must be `:all`, `:none`, an `OutputRequest`, or a vector of `OutputRequest`." ) return collect(requests) end - -""" - prepare_output_requests!(sim, requests, timeline) - -Resolve and register online export requests for the current run. -""" -function prepare_output_requests!(sim::GraphSimulation, requests, timeline::TimelineContext) - reqs = _normalize_output_requests(requests) - - plans = Any[] - rows = Dict{Symbol,ExportBuffer}() - - for req in reqs - scale = req.scale - process = isnothing(req.process) ? _canonical_source_process(sim, scale, req.var) : req.process - model_spec = _model_spec_for_process(sim, scale, process) - source_model = get_models(sim)[scale][process] - source_clock = _model_clock(model_spec, source_model, timeline) - clock = _export_clock(req, timeline) - - haskey(rows, req.name) && error( - "Duplicate output request name `$(req.name)`. Request names must be unique." - ) - - push!(plans, ( - name=req.name, - scale=scale, - var=req.var, - process=process, - policy=req.policy, - clock=clock, - model_spec=model_spec, - source_dt=float(source_clock.dt), - source_sample_duration_seconds=float(source_clock.dt) * timeline.base_step_seconds, - )) - rows[req.name] = ExportBuffer(scale, process, req.var) - end - - sim.temporal_state.export_plans = plans - sim.temporal_state.export_rows = rows - return nothing -end - -function _required_horizon_for_export_policy(policy::SchedulePolicy, clock::ClockSpec, source_dt::Float64) - if policy isa Union{Integrate,Aggregate} - return max(1.0, float(clock.dt)) - elseif policy isa Interpolate - return max(2.0, source_dt + 1.0) - end - # HoldLast export is served from caches and does not require streams. - return 0.0 -end - -""" - export_horizon_requirements(sim) - -Return additional producer horizon requirements induced by configured online -export requests. -""" -function export_horizon_requirements(sim::GraphSimulation) - horizons = Dict{Tuple{Symbol,Symbol,Symbol},Float64}() - for plan in sim.temporal_state.export_plans - required = _required_horizon_for_export_policy(plan.policy, plan.clock, plan.source_dt) - required <= 0.0 && continue - key = (plan.scale, plan.process, plan.var) - horizons[key] = max(get(horizons, key, 0.0), required) - end - return horizons -end - -function _resolve_output_value_online( - sim::GraphSimulation, - policy::HoldLast, - scope::ScopeId, - scale::Symbol, - process::Symbol, - var::Symbol, - nodeid::Int, - t::Float64, - t_start::Float64, - source_sample_duration_seconds::Float64 -) - v, ok = _resolved_value_for_source(sim, scope, scale, process, var, nodeid, t) - return ok ? v : missing -end - -function _resolve_output_value_online( - sim::GraphSimulation, - policy::Interpolate, - scope::ScopeId, - scale::Symbol, - process::Symbol, - var::Symbol, - nodeid::Int, - t::Float64, - t_start::Float64, - source_sample_duration_seconds::Float64 -) - v, ok = _resolved_interpolated_value_for_source(sim, scope, scale, process, var, nodeid, t, policy) - return ok ? v : missing -end - -function _resolve_output_value_online( - sim::GraphSimulation, - policy::Union{Integrate,Aggregate}, - scope::ScopeId, - scale::Symbol, - process::Symbol, - var::Symbol, - nodeid::Int, - t::Float64, - t_start::Float64, - source_sample_duration_seconds::Float64 -) - v, ok = _resolved_windowed_value_for_source( - sim, - scope, - scale, - process, - var, - nodeid, - t_start, - t, - policy, - source_sample_duration_seconds - ) - return ok ? v : missing -end - -""" - update_requested_outputs!(sim, t) - -Materialize configured output requests online at runtime time `t`. -""" -function update_requested_outputs!(sim::GraphSimulation, t::Float64) - isempty(sim.temporal_state.export_plans) && return nothing - timestep = Int(round(t)) - - for plan in sim.temporal_state.export_plans - _should_run_at_time(plan.clock, t) || continue - source_statuses = get(status(sim), plan.scale, nothing) - isnothing(source_statuses) && continue - buf = sim.temporal_state.export_rows[plan.name] - - t_start = _window_start_for_clock(plan.clock, t) - for st in source_statuses - scope = _scope_for_status(sim, plan.model_spec, plan.scale, plan.process, st.node) - nodeid = node_id(st.node) - v = _resolve_output_value_online( - sim, - plan.policy, - scope, - plan.scale, - plan.process, - plan.var, - nodeid, - t, - t_start, - plan.source_sample_duration_seconds, - ) - - push!(buf.timestep, timestep) - push!(buf.node, nodeid) - push!(buf.value, v) - end - end - - return nothing -end - -function _materialize_output_rows(rows::ExportBuffer, sink) - n = length(rows.timestep) - scale_col = fill(rows.scale, n) - process_col = fill(rows.process, n) - var_col = fill(rows.var, n) - - if sink === DataFrames.DataFrame - return DataFrames.DataFrame( - timestep=rows.timestep, - scale=scale_col, - process=process_col, - var=var_col, - node=rows.node, - value=rows.value, - ) - end - - table = Vector{NamedTuple{(:timestep, :scale, :process, :var, :node, :value),Tuple{Int,Symbol,Symbol,Symbol,Int,Any}}}(undef, n) - @inbounds for i in 1:n - table[i] = ( - timestep=rows.timestep[i], - scale=scale_col[i], - process=process_col[i], - var=var_col[i], - node=rows.node[i], - value=rows.value[i], - ) - end - - isnothing(sink) && return table - return sink(table) -end - -""" - collect_outputs(sim; sink=DataFrame) - -Return online-exported output rows configured for the run. -""" -function collect_outputs(sim::GraphSimulation; sink=DataFrames.DataFrame) - out = Dict{Symbol,Any}() - for (name, rows) in sim.temporal_state.export_rows - out[name] = _materialize_output_rows(rows, sink) - end - return out -end - -function collect_outputs(sim::GraphSimulation, name::Symbol; sink=DataFrames.DataFrame) - haskey(sim.temporal_state.export_rows, name) || error( - "Unknown output request name `$(name)`." - ) - return _materialize_output_rows(sim.temporal_state.export_rows[name], sink) -end diff --git a/src/time/runtime/publishers.jl b/src/time/runtime/publishers.jl deleted file mode 100644 index ff314ece2..000000000 --- a/src/time/runtime/publishers.jl +++ /dev/null @@ -1,175 +0,0 @@ -""" - _policy_for_output(model, var) - -Return the per-output schedule policy for `var`, defaulting to `HoldLast()`. -""" -function _policy_for_output(model, var::Symbol) - pol = output_policy(model) - var in keys(pol) || return HoldLast() - return _as_schedule_policy(pol[var]; context="output_policy for output `$(var)` in model `$(typeof(model))`") -end - -""" - _publish_mode_for_output(model_spec, var) - -Return output routing mode for `var` (`:canonical` or `:stream_only`), with -validation and default `:canonical`. -""" -function _publish_mode_for_output(model_spec, var::Symbol) - modes = output_routing(model_spec) - mode = var in keys(modes) ? modes[var] : :canonical - mode in (:canonical, :stream_only) || error( - "Unsupported output routing mode `$(mode)` for output `$(var)`. ", - "Allowed values are `:canonical` and `:stream_only`." - ) - return mode -end - -""" - validate_canonical_publishers(sim) - -Ensure that each `(scale, variable)` has at most one canonical publisher. -Throws when multiple producers publish the same canonical output. -""" -function validate_canonical_publishers(sim::GraphSimulation) - ignored_same_rate_hard_children = _same_rate_hard_dependency_children(get_model_specs(sim), dep(sim)) - for (scale, models_at_scale) in get_models(sim) - specs_at_scale = get_model_specs(sim)[scale] - ignored_at_scale = get(ignored_same_rate_hard_children, scale, Set{Symbol}()) - publishers = Dict{Symbol,Vector{Symbol}}() - for (process, model) in pairs(models_at_scale) - process in ignored_at_scale && continue - model_spec = get(specs_at_scale, process, as_model_spec(model)) - for var in keys(outputs_(model)) - _publish_mode_for_output(model_spec, var) == :stream_only && continue - if !haskey(publishers, var) - publishers[var] = Symbol[process] - else - push!(publishers[var], process) - end - end - end - - for (var, procs) in publishers - if length(procs) > 1 - error( - "Ambiguous canonical publishers for variable `$(var)` at scale `$(scale)`: ", - join(procs, ", "), - ". Declare `OutputRouting(; $(var)=:stream_only)` for non-canonical producers." - ) - end - end - end - return nothing -end - -_producer_signature(scale::Symbol, process::Symbol, var::Symbol) = (scale, process, var) - -function _max_horizon!(horizons::Dict{Tuple{Symbol,Symbol,Symbol},Float64}, key::Tuple{Symbol,Symbol,Symbol}, required::Float64) - horizons[key] = max(get(horizons, key, 0.0), required) - return nothing -end - -function _required_horizon_for_policy(policy::SchedulePolicy, consumer_dt::Float64, source_dt::Float64) - if policy isa Union{Integrate,Aggregate} - return max(1.0, consumer_dt) - elseif policy isa Interpolate - # Keep at least two source samples for interpolation/extrapolation. - return max(2.0, source_dt + 1.0) - end - return 0.0 -end - -function _consumer_horizon_requirements(sim::GraphSimulation, timeline::TimelineContext) - horizons = Dict{Tuple{Symbol,Symbol,Symbol},Float64}() - - dep_nodes = traverse_dependency_graph(dep(sim), false) - for node in dep_nodes - model_spec = _model_spec_for_process(sim, node.scale, node.process) - consumer_clock = _model_clock(model_spec, node.value, timeline) - consumer_dt = float(consumer_clock.dt) - - for (input_var, raw_binding) in pairs(input_bindings(model_spec)) - parsed = _parse_input_binding(raw_binding) - isnothing(parsed) && continue - isnothing(parsed.process) && continue - source_process = parsed.process - source_var = isnothing(parsed.var) ? input_var : parsed.var - source_scale = isnothing(parsed.scale) ? _source_scale_for_process(node, source_process) : parsed.scale - source_scale = source_scale isa AbstractString ? - _normalize_scale(source_scale; warn=true, context=:ModelSpec) : - source_scale - source_model_spec = _model_spec_for_process(sim, source_scale, source_process) - source_model = get_models(sim)[source_scale][source_process] - source_clock = _model_clock(source_model_spec, source_model, timeline) - source_dt = float(source_clock.dt) - required = _required_horizon_for_policy(parsed.policy, consumer_dt, source_dt) - required <= 0.0 && continue - key = _producer_signature(source_scale, source_process, source_var) - _max_horizon!(horizons, key, required) - end - end - - return horizons -end - -""" - configure_temporal_buffers!(sim, timeline) - -Prepare bounded producer streams used at runtime and online export. -""" -function configure_temporal_buffers!(sim::GraphSimulation, timeline::TimelineContext) - horizons = _consumer_horizon_requirements(sim, timeline) - for (key, required) in export_horizon_requirements(sim) - _max_horizon!(horizons, key, required) - end - sim.temporal_state.producer_horizons = horizons - empty!(sim.temporal_state.streams) - empty!(sim.temporal_state.caches) - empty!(sim.temporal_state.last_run) - return nothing -end - -function _trim_stream!(samples::Vector{Tuple{Float64,Any}}, t::Float64, horizon::Float64) - horizon <= 0.0 && (empty!(samples); return nothing) - t_min = t - horizon + 1.0 - 1e-8 - first_keep = findfirst(s -> s[1] >= t_min, samples) - isnothing(first_keep) && (empty!(samples); return nothing) - first_keep > 1 && deleteat!(samples, 1:first_keep-1) - return nothing -end - -""" - update_temporal_state_outputs!(sim, node, model_spec, st, t) - -Store producer outputs at time `t` into temporal streams and hold-last caches. -Also updates `last_run` for the emitting model key. -""" -function update_temporal_state_outputs!(sim::GraphSimulation, node::SoftDependencyNode, model_spec, st::Status, t::Float64) - model = node.value - outs = keys(outputs_(model)) - length(outs) == 0 && return nothing - - scope = _scope_for_status(sim, model_spec, node.scale, node.process, st.node) - nodeid = node_id(st.node) - mkey = ModelKey(scope, node.scale, node.process) - sim.temporal_state.last_run[mkey] = t - - for out_var in outs - val = st[out_var] - key = OutputKey(scope, node.scale, nodeid, node.process, out_var) - producer_key = _producer_signature(node.scale, node.process, out_var) - - if haskey(sim.temporal_state.producer_horizons, producer_key) - samples = get!(sim.temporal_state.streams, key, Vector{Tuple{Float64,Any}}()) - push!(samples, (t, val)) - _trim_stream!(samples, t, sim.temporal_state.producer_horizons[producer_key]) - end - - policy = _policy_for_output(model, out_var) - policy isa HoldLast || continue - sim.temporal_state.caches[key] = HoldLastCache(t, val) - end - - return nothing -end diff --git a/src/time/runtime/scopes.jl b/src/time/runtime/scopes.jl deleted file mode 100644 index 2c5c00e4b..000000000 --- a/src/time/runtime/scopes.jl +++ /dev/null @@ -1,109 +0,0 @@ -const _BUILTIN_SCOPE_SELECTORS = (:global, :plant, :scene, :self) - -""" - _model_spec_for_process(sim, scale, process) - -Return the normalized `ModelSpec` for one `(scale, process)` pair. -""" -function _model_spec_for_process(sim::GraphSimulation, scale::Symbol, process::Symbol) - specs_at_scale = get_model_specs(sim)[scale] - if haskey(specs_at_scale, process) - return specs_at_scale[process] - end - - models_at_scale = get_models(sim)[scale] - haskey(models_at_scale, process) || error( - "Cannot resolve model spec for process `$(process)` at scale `$(scale)`." - ) - return as_model_spec(models_at_scale[process]) -end - -function _find_ancestor_by_symbol(node, target::Symbol) - current = node - while !isnothing(current) - symbol(current) == target && return current - current = parent(current) - end - return nothing -end -_find_ancestor_by_symbol(node, target::AbstractString) = _find_ancestor_by_symbol(node, Symbol(target)) - -function _scope_from_builtin(selector::Symbol, node, scale::Symbol, process::Symbol) - if selector == :global - return ScopeId(:global, 1) - elseif selector == :self - return ScopeId(:self, node_id(node)) - elseif selector == :plant - plant = _find_ancestor_by_symbol(node, :Plant) - isnothing(plant) && error( - "Scope selector `:plant` for process `$(process)` at scale `$(scale)` ", - "could not find a `Plant` ancestor for node `$(node_id(node))`." - ) - return ScopeId(:plant, node_id(plant)) - elseif selector == :scene - scene = _find_ancestor_by_symbol(node, :Scene) - isnothing(scene) && error( - "Scope selector `:scene` for process `$(process)` at scale `$(scale)` ", - "could not find a `Scene` ancestor for node `$(node_id(node))`." - ) - return ScopeId(:scene, node_id(scene)) - end - - error( - "Unsupported scope selector `$(selector)` for process `$(process)` at scale `$(scale)`. ", - "Supported selectors are $(_BUILTIN_SCOPE_SELECTORS), `ScopeId`, or a callable." - ) -end - -function _scope_from_selector_result(result, node, scale::Symbol, process::Symbol) - if result isa ScopeId - return result - elseif result isa Symbol - return _scope_from_builtin(result, node, scale, process) - elseif result isa AbstractString - return _scope_from_builtin(Symbol(result), node, scale, process) - end - - error( - "Scope selector for process `$(process)` at scale `$(scale)` must return `ScopeId`, `Symbol`, or `String`, ", - "got `$(typeof(result))`." - ) -end - -function _scope_from_selector(selector, node, scale::Symbol, process::Symbol) - if selector isa ScopeId - return selector - elseif selector isa Symbol - return _scope_from_builtin(selector, node, scale, process) - elseif selector isa AbstractString - return _scope_from_builtin(Symbol(selector), node, scale, process) - elseif selector isa Function - result = if applicable(selector, node, scale, process) - selector(node, scale, process) - elseif applicable(selector, node, scale) - selector(node, scale) - elseif applicable(selector, node) - selector(node) - else - error( - "Scope callable for process `$(process)` at scale `$(scale)` must accept `(node)`, `(node, scale)` ", - "or `(node, scale, process)`." - ) - end - return _scope_from_selector_result(result, node, scale, process) - end - - error( - "Unsupported scope selector type `$(typeof(selector))` for process `$(process)` at scale `$(scale)`." - ) -end - -""" - _scope_for_status(sim, model_spec, scale, process, node) - -Resolve the effective `ScopeId` for one node status and one model process. -""" -function _scope_for_status(sim::GraphSimulation, model_spec, scale::Symbol, process::Symbol, node) - selector = isnothing(model_spec) ? :global : model_scope(model_spec) - return _scope_from_selector(selector, node, scale, process) -end diff --git a/src/traits/parallel_traits.jl b/src/traits/parallel_traits.jl deleted file mode 100644 index b4c927e4b..000000000 --- a/src/traits/parallel_traits.jl +++ /dev/null @@ -1,302 +0,0 @@ -""" - DependencyTrait(T::Type) - -Returns information about the eventual dependence of a model `T` to other time-steps or objects -for its computation. The dependence trait is used to determine if a model is parallelizable -or not. - -The following dependence traits are supported: - -- `TimeStepDependencyTrait`: Trait that defines whether a model can be parallelizable over time-steps for its computation. -- `ObjectDependencyTrait`: Trait that defines whether a model can be parallelizable over objects for its computation. -""" -abstract type DependencyTrait end - -abstract type TimeStepDependencyTrait <: DependencyTrait end -struct IsTimeStepDependent <: TimeStepDependencyTrait end -struct IsTimeStepIndependent <: TimeStepDependencyTrait end - -""" - TimeStepDependencyTrait(::Type{T}) - -Defines the trait about the eventual dependence of a model `T` to other time-steps for its computation. -This dependency trait is used to determine if a model is parallelizable over time-steps or not. - -The following dependency traits are supported: - -- `IsTimeStepDependent`: The model depends on other time-steps for its computation, it cannot be run in parallel. -- `IsTimeStepIndependent`: The model does not depend on other time-steps for its computation, it can be run in parallel. - -All models are time-step dependent by default (*i.e.* `IsTimeStepDependent`). This is probably not right for the -majority of models, but: - -1. It is the safest default, as it will not lead to incorrect results if the user forgets to override this trait -which is not the case for the opposite (i.e. `IsTimeStepIndependent`) -2. It is easy to override this trait for models that are time-step independent - -# See also - -- [`timestep_parallelizable`](@ref): Returns `true` if the model is parallelizable over time-steps, and `false` otherwise. -- [`object_parallelizable`](@ref): Returns `true` if the model is parallelizable over objects, and `false` otherwise. -- [`parallelizable`](@ref): Returns `true` if the model is parallelizable, and `false` otherwise. -- [`ObjectDependencyTrait`](@ref): Defines the trait about the eventual dependence of a model to other objects for its computation. - -# Examples - -Define a dummy process: -```julia -using PlantSimEngine - -# Define a test process: -@process "TestProcess" -``` - -Define a model that is time-step independent: - -```julia -struct MyModel <: AbstractTestprocessModel end - -# Override the time-step dependency trait: -PlantSimEngine.TimeStepDependencyTrait(::Type{MyModel}) = IsTimeStepIndependent() -``` - -Check if the model is parallelizable over time-steps: - -```julia -timestep_parallelizable(MyModel()) # false -``` - -Define a model that is time-step dependent: - -```julia -struct MyModel2 <: AbstractTestprocessModel end - -# Override the time-step dependency trait: -PlantSimEngine.TimeStepDependencyTrait(::Type{MyModel2}) = IsTimeStepDependent() -``` - -Check if the model is parallelizable over time-steps: - -```julia -timestep_parallelizable(MyModel()) # true -``` -""" -TimeStepDependencyTrait(::Type) = IsTimeStepDependent() - -""" - timestep_parallelizable(x::T) - timestep_parallelizable(x::DependencyGraph) - -Returns `true` if the model `x` is parallelizable, i.e. if the model can be computed in parallel -over time-steps, or `false` otherwise. - -The default implementation returns `false` for all models. -If you develop a model that is parallelizable over time-steps, you should add a method to [`ObjectDependencyTrait`](@ref) -for your model. - -Note that this method can also be applied on a [`DependencyGraph`](@ref) directly, in which case it returns `true` if all -models in the graph are parallelizable, and `false` otherwise. - -# See also - -- [`object_parallelizable`](@ref): Returns `true` if the model is parallelizable over time-steps, and `false` otherwise. -- [`parallelizable`](@ref): Returns `true` if the model is parallelizable, and `false` otherwise. -- [`TimeStepDependencyTrait`](@ref): Defines the trait about the eventual dependence of a model to other time-steps for its computation. - -# Examples - -Define a dummy process: -```julia -using PlantSimEngine - -# Define a test process: -@process "TestProcess" -``` - -Define a model that is time-step independent: - -```julia -struct MyModel <: AbstractTestprocessModel end - -# Override the time-step dependency trait: -PlantSimEngine.TimeStepDependencyTrait(::Type{MyModel}) = IsTimeStepIndependent() -``` - -Check if the model is parallelizable over objects: - -```julia -timestep_parallelizable(MyModel()) # true -``` -""" -timestep_parallelizable(x::T) where {T} = timestep_parallelizable(TimeStepDependencyTrait(T), x) -timestep_parallelizable(::IsTimeStepDependent, x) = false -timestep_parallelizable(::IsTimeStepIndependent, x) = true - -""" - ObjectDependencyTrait(::Type{T}) - -Defines the trait about the eventual dependence of a model `T` to other objects for its computation. -This dependency trait is used to determine if a model is parallelizable over objects or not. - -The following dependency traits are supported: - -- `IsObjectDependent`: The model depends on other objects for its computation, it cannot be run in parallel. -- `IsObjectIndependent`: The model does not depend on other objects for its computation, it can be run in parallel. - -All models are object dependent by default (*i.e.* `IsObjectDependent`). This is probably not right for the -majority of models, but: - -1. It is the safest default, as it will not lead to incorrect results if the user forgets to override this trait -which is not the case for the opposite (i.e. `IsObjectIndependent`) -2. It is easy to override this trait for models that are object independent - -# See also - -- [`timestep_parallelizable`](@ref): Returns `true` if the model is parallelizable over time-steps, and `false` otherwise. -- [`object_parallelizable`](@ref): Returns `true` if the model is parallelizable over objects, and `false` otherwise. -- [`parallelizable`](@ref): Returns `true` if the model is parallelizable, and `false` otherwise. -- [`TimeStepDependencyTrait`](@ref): Defines the trait about the eventual dependence of a model to other time-steps for its computation. - -# Examples - -Define a dummy process: -```julia -using PlantSimEngine - -# Define a test process: -@process "TestProcess" -``` - -Define a model that is object independent: - -```julia -struct MyModel <: AbstractTestprocessModel end - -# Override the object dependency trait: -PlantSimEngine.ObjectDependencyTrait(::Type{MyModel}) = IsObjectIndependent() -``` - -Check if the model is parallelizable over objects: - -```julia -object_parallelizable(MyModel()) # false -``` - -Define a model that is object dependent: - -```julia -struct MyModel2 <: AbstractTestprocessModel end - -# Override the object dependency trait: -PlantSimEngine.ObjectDependencyTrait(::Type{MyModel2}) = IsObjectDependent() -``` - -Check if the model is parallelizable over objects: - -```julia -object_parallelizable(MyModel()) # true -``` -""" -abstract type ObjectDependencyTrait <: DependencyTrait end -struct IsObjectDependent <: ObjectDependencyTrait end -struct IsObjectIndependent <: ObjectDependencyTrait end -ObjectDependencyTrait(::Type) = IsObjectDependent() - -""" - object_parallelizable(x::T) - object_parallelizable(x::DependencyGraph) - -Returns `true` if the model `x` is parallelizable, i.e. if the model can be computed in parallel -for different objects, or `false` otherwise. - -The default implementation returns `false` for all models. -If you develop a model that is parallelizable over objects, you should add a method to [`ObjectDependencyTrait`](@ref) -for your model. - -Note that this method can also be applied on a [`DependencyGraph`](@ref) directly, in which case it returns `true` if all -models in the graph are parallelizable, and `false` otherwise. - -# See also - -- [`timestep_parallelizable`](@ref): Returns `true` if the model is parallelizable over time-steps, and `false` otherwise. -- [`parallelizable`](@ref): Returns `true` if the model is parallelizable, and `false` otherwise. -- [`ObjectDependencyTrait`](@ref): Defines the trait about the eventual dependence of a model to other objects for its computation. - -# Examples - -Define a dummy process: -```julia -using PlantSimEngine - -# Define a test process: -@process "TestProcess" -``` - -Define a model that is object independent: - -```julia -struct MyModel <: AbstractTestprocessModel end - -# Override the object dependency trait: -PlantSimEngine.ObjectDependencyTrait(::Type{MyModel}) = IsObjectIndependent() -``` - -Check if the model is parallelizable over objects: - -```julia -object_parallelizable(MyModel()) # true -``` -""" -object_parallelizable(x::T) where {T} = object_parallelizable(ObjectDependencyTrait(T), x) -object_parallelizable(::IsObjectDependent, x) = false -object_parallelizable(::IsObjectIndependent, x) = true - -""" - parallelizable(::T) - object_parallelizable(x::DependencyGraph) - -Returns `true` if the model `T` or the whole dependency graph is parallelizable, *i.e.* if the model can be computed in parallel -for different time-steps or objects. The default implementation returns `false` for all models. - -# See also - -- [`timestep_parallelizable`](@ref): Returns `true` if the model is parallelizable over time-steps, and `false` otherwise. -- [`object_parallelizable`](@ref): Returns `true` if the model is parallelizable over objects, and `false` otherwise. -- [`TimeStepDependencyTrait`](@ref): Defines the trait about the eventual dependence of a model to other time-steps for its computation. - -# Examples - -Define a dummy process: -```julia -using PlantSimEngine - -# Define a test process: -@process "TestProcess" -``` - -Define a model that is parallelizable: - -```julia -struct MyModel <: AbstractTestprocessModel end - -# Override the time-step dependency trait: -PlantSimEngine.TimeStepDependencyTrait(::Type{MyModel}) = IsTimeStepIndependent() - -# Override the object dependency trait: -PlantSimEngine.ObjectDependencyTrait(::Type{MyModel}) = IsObjectIndependent() -``` - -Check if the model is parallelizable: - -```julia -parallelizable(MyModel()) # true -``` - -Or if we want to be more explicit: - -```julia -timestep_parallelizable(MyModel()) -object_parallelizable(MyModel()) -``` -""" -parallelizable(x::T) where {T} = timestep_parallelizable(x) && object_parallelizable(x) \ No newline at end of file diff --git a/src/traits/table_traits.jl b/src/traits/table_traits.jl index e4eab2f83..0adbe89dd 100644 --- a/src/traits/table_traits.jl +++ b/src/traits/table_traits.jl @@ -1,7 +1,6 @@ abstract type DataFormat end struct TableAlike <: DataFormat end struct SingletonAlike <: DataFormat end -struct TreeAlike <: DataFormat end """ DataFormat(T::Type) @@ -13,11 +12,9 @@ how to iterate over the data. The following data formats are supported: `TimeStepTable`. The data is iterated over by rows using the `Tables.jl` interface. - `SingletonAlike`: The data is a singleton-like object, e.g. a `NamedTuple` or a `TimeStepRow`. The data is iterated over by columns. -- `TreeAlike`: The data is a tree-like object, e.g. a `Node`. - The default implementation returns `TableAlike` for `AbstractDataFrame`, -`TimeStepTable`, `AbstractVector` and `Dict`, `TreeAlike` for `GraphSimulation`, -`SingletonAlike` for `Status`, `ModelList`, `NamedTuple` and `TimeStepRow`. +`TimeStepTable`, `AbstractVector`, and `Dict`; and `SingletonAlike` for +`Status`, `NamedTuple`, and `TimeStepRow`. The default implementation for `Any` throws an error. Users that want to use another input should define this trait for the new data format, e.g.: @@ -51,15 +48,11 @@ DataFormat(::Type{<:DataFrames.AbstractDataFrame}) = TableAlike() DataFormat(::Type{<:PlantMeteo.TimeStepTable}) = TableAlike() DataFormat(::Type{<:PlantMeteo.TimeStepRows}) = TableAlike() -# Giving a ModelList as a vector or a dict of objects: DataFormat(::Type{<:AbstractVector}) = TableAlike() DataFormat(::Type{<:Dict}) = TableAlike() DataFormat(::Type{<:NamedTuple}) = SingletonAlike() DataFormat(::Type{<:Status}) = SingletonAlike() -DataFormat(::Type{<:ModelList{Mo,S} where {Mo,S}}) = SingletonAlike() -DataFormat(::Type{<:ModelMapping{SingleScale}}) = SingletonAlike() -DataFormat(::Type{<:GraphSimulation}) = TreeAlike() DataFormat(::Type{<:PlantMeteo.AbstractAtmosphere}) = SingletonAlike() DataFormat(::Type{<:PlantMeteo.TimeStepRow}) = SingletonAlike() @@ -67,3 +60,8 @@ DataFormat(::Type{<:Nothing}) = SingletonAlike() # For meteo == Nothing DataFormat(T::Type{<:Any}) = error("Unknown data format: $T.\nPlease define a `DataFormat` method, e.g.: DataFormat(::Type{$T}) method.") DataFormat(x::T) where {T} = DataFormat(T) DataFormat(::Type{<:DataFrames.DataFrameRow}) = SingletonAlike() + +get_nsteps(value) = get_nsteps(DataFormat(value), value) +get_nsteps(::SingletonAlike, value) = 1 +get_nsteps(::TableAlike, value) = length(Tables.rows(value)) +get_nsteps(::TableAlike, value::PlantMeteo.TimeStepRows) = length(value) diff --git a/src/variables_wrappers.jl b/src/variables_wrappers.jl index b6accd3aa..72271d084 100644 --- a/src/variables_wrappers.jl +++ b/src/variables_wrappers.jl @@ -1,28 +1,9 @@ -""" - UninitializedVar(variable, value) - -A variable that is not initialized yet, it is given a name and a default value. -""" -struct UninitializedVar{T} - variable::Symbol - value::T -end - -Base.eltype(u::UninitializedVar{T}) where {T} = T -source_variable(m::UninitializedVar) = m.variable -source_variable(m::UninitializedVar, org) = m.variable - """ PreviousTimeStep(variable) -A structure to manually flag a variable in a model to use the value computed on the previous time-step. -This implies that the variable is not used to build the dependency graph because the dependency graph only -applies on the current time-step. This is used to avoid circular dependencies when a variable depends on itself. +A structure to flag a model input as using the value computed on the previous +model timestep. This breaks same-timestep coupling cycles. The value can be initialized in the Status if needed. - -The process is added when building the MultiScaleModel, to avoid conflicts between processes with the same variable name. -For exemple one process can define a variable `:carbon_biomass` as a `PreviousTimeStep`, but the othe process would use -the variable as a dependency for the current time-step (and it would be fine because theyr don't share the same issue of cyclic dependency). """ struct PreviousTimeStep variable::Symbol @@ -30,15 +11,3 @@ struct PreviousTimeStep end PreviousTimeStep(v::Symbol) = PreviousTimeStep(v, :unknown) - -""" - RefVariable(reference_variable) - -A structure to manually flag a variable in a model to use the value of another variable **at the same scale**. -This is used for variable renaming, when a variable is computed by a model but is used by another model with a different name. - -Note: we don't really rename the variable in the status (we need it for the other models), but we create a new one that is a reference to the first one. -""" -struct RefVariable - reference_variable::Symbol -end \ No newline at end of file diff --git a/src/visualization/model_graph_editor_api.jl b/src/visualization/model_graph_editor_api.jl new file mode 100644 index 000000000..3684faf03 --- /dev/null +++ b/src/visualization/model_graph_editor_api.jl @@ -0,0 +1,942 @@ +abstract type AbstractModelGraphEdit end + +struct AddModelApplication{S} <: AbstractModelGraphEdit + spec::S +end + +struct RemoveModelApplication <: AbstractModelGraphEdit + application_id::Symbol +end + +struct RemoveModelTemplateApplication <: AbstractModelGraphEdit + instance::Symbol + application_id::Symbol +end + +RemoveModelTemplateApplication( + instance::Union{Symbol,AbstractString}, + application_id::Union{Symbol,AbstractString}, +) = + RemoveModelTemplateApplication(Symbol(instance), Symbol(application_id)) + +struct ReplaceModelApplicationModel{M<:AbstractModel} <: AbstractModelGraphEdit + application_id::Symbol + model::M +end + +struct UpdateModelApplication{M<:AbstractModel,S,T} <: AbstractModelGraphEdit + application_id::Symbol + model::M + name::Symbol + selector::S + timestep::T +end + +struct UpdateModelTemplateApplication{M<:AbstractModel,S,T} <: AbstractModelGraphEdit + instance::Symbol + application_id::Symbol + model::M + selector::S + timestep::T +end + +struct RenameModelApplication <: AbstractModelGraphEdit + application_id::Symbol + name::Symbol +end + +struct SetModelApplicationTargets{S} <: AbstractModelGraphEdit + application_id::Symbol + selector::S +end + +struct SetModelInputBinding{S} <: AbstractModelGraphEdit + application_id::Symbol + input::Symbol + selector::S +end + +struct RemoveModelInputBinding <: AbstractModelGraphEdit + application_id::Symbol + input::Symbol +end + +struct SetModelCallBinding{S} <: AbstractModelGraphEdit + application_id::Symbol + call::Symbol + selector::S +end + +struct RemoveModelCallBinding <: AbstractModelGraphEdit + application_id::Symbol + call::Symbol +end + +struct SetModelApplicationTimeStep{T} <: AbstractModelGraphEdit + application_id::Symbol + timestep::T +end + +struct SetModelApplicationEnvironment{C} <: AbstractModelGraphEdit + application_id::Symbol + configuration::C +end + +struct SetModelOutputRouting <: AbstractModelGraphEdit + application_id::Symbol + output::Symbol + route::Symbol +end + +struct SetModelUpdateOrdering{U} <: AbstractModelGraphEdit + application_id::Symbol + updates::U +end + +struct MarkModelPreviousTimeStep <: AbstractModelGraphEdit + application_id::Symbol + input::Symbol +end + +struct UnmarkModelPreviousTimeStep <: AbstractModelGraphEdit + application_id::Symbol + input::Symbol +end + +struct BreakModelCycle{V} <: AbstractModelGraphEdit + application_id::Symbol + input::Symbol + initialize_missing::Bool + initial_value::V +end + +struct AddModelObject <: AbstractModelGraphEdit + object::Object +end + +struct RemoveModelObject <: AbstractModelGraphEdit + object_id::ObjectId + recursive::Bool +end + +RemoveModelObject(object_id; recursive::Bool=true) = + RemoveModelObject(ObjectId(object_id), recursive) + +struct ReparentModelObject <: AbstractModelGraphEdit + object_id::ObjectId + parent_id::Union{Nothing,ObjectId} +end + +ReparentModelObject( + object_id::Union{ObjectId,Symbol,AbstractString,Integer}, + parent_id::Union{Nothing,ObjectId,Symbol,AbstractString,Integer}, +) = ReparentModelObject( + ObjectId(object_id), + isnothing(parent_id) ? nothing : ObjectId(parent_id), +) + +struct SetModelObjectStatus{V} <: AbstractModelGraphEdit + object_id::ObjectId + variable::Symbol + value::V +end + +SetModelObjectStatus(object_id, variable, value) = + SetModelObjectStatus(ObjectId(object_id), Symbol(variable), value) + +struct SetModelObjectStatuses{V} <: AbstractModelGraphEdit + object_ids::Vector{ObjectId} + variable::Symbol + value::V +end + + +SetModelObjectStatuses(object_ids, variable, value) = SetModelObjectStatuses( + ObjectId[ObjectId(object_id) for object_id in object_ids], + Symbol(variable), + value, +) + +struct RemoveModelObjectStatus <: AbstractModelGraphEdit + object_id::ObjectId + variable::Symbol +end + +RemoveModelObjectStatus( + object_id::Union{ObjectId,Symbol,AbstractString,Integer}, + variable::Union{Symbol,AbstractString}, +) = + RemoveModelObjectStatus(ObjectId(object_id), Symbol(variable)) + +struct SetModelObjectMetadata{C} <: AbstractModelGraphEdit + object_id::ObjectId + configuration::C +end + +SetModelObjectMetadata(object_id; kwargs...) = + SetModelObjectMetadata(ObjectId(object_id), (; kwargs...)) + +struct SetModelInstanceOverride{M<:AbstractModel} <: AbstractModelGraphEdit + instance::Symbol + application_id::Symbol + model::M +end + +SetModelInstanceOverride(instance, application_id, model::AbstractModel) = + SetModelInstanceOverride(Symbol(instance), Symbol(application_id), model) + +struct RemoveModelInstanceOverride <: AbstractModelGraphEdit + instance::Symbol + application_id::Symbol +end + +RemoveModelInstanceOverride( + instance::Union{Symbol,AbstractString}, + application_id::Union{Symbol,AbstractString}, +) = + RemoveModelInstanceOverride(Symbol(instance), Symbol(application_id)) + +struct SetModelObjectOverride{M<:AbstractModel} <: AbstractModelGraphEdit + instance::Symbol + object_id::ObjectId + application_id::Symbol + model::M +end + +SetModelObjectOverride(instance, object_id, application_id, model::AbstractModel) = + SetModelObjectOverride(Symbol(instance), ObjectId(object_id), Symbol(application_id), model) + +struct RemoveModelObjectOverride <: AbstractModelGraphEdit + instance::Symbol + object_id::ObjectId + application_id::Symbol +end + +RemoveModelObjectOverride( + instance::Union{Symbol,AbstractString}, + object_id::Union{ObjectId,Symbol,AbstractString,Integer}, + application_id::Union{Symbol,AbstractString}, +) = + RemoveModelObjectOverride(Symbol(instance), ObjectId(object_id), Symbol(application_id)) + +""" + apply_model_graph_edit(model, edit) + +Apply one declarative graph edit transactionally. The input model is not +modified; a deep-copied, cache-invalidated CompositeModel is returned. Configuration +that is temporarily incomplete or cyclic is retained so the editor can display +and repair it. Structural edit errors leave the original model unchanged. +""" +function apply_model_graph_edit(model::CompositeModel, edit::AbstractModelGraphEdit) + candidate = deepcopy(model) + candidate = _apply_model_graph_edit!(candidate, edit) + _mark_bindings_dirty!(candidate) + return candidate +end + +function _model_edit_application_id(spec) + normalized = as_model_spec(spec) + name = application_name(normalized) + return isnothing(name) ? process(normalized) : name +end + +function _model_edit_application_index(model::CompositeModel, application_id::Symbol) + matches = Int[ + index for (index, spec) in pairs(model.applications) + if _model_edit_application_id(spec) == application_id + ] + isempty(matches) && error("CompositeModel has no application `$(application_id)`.") + length(matches) == 1 || error( + "CompositeModel application id `$(application_id)` is ambiguous. Name repeated applications explicitly.", + ) + return only(matches) +end + +function _model_edit_spec(model::CompositeModel, application_id::Symbol) + return as_model_spec(model.applications[_model_edit_application_index(model, application_id)]) +end + +function _replace_model_edit_spec!(model::CompositeModel, application_id::Symbol, spec) + index = _model_edit_application_index(model, application_id) + model.applications[index] = spec + return model +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::AddModelApplication) + spec = as_model_spec(edit.spec) + application_id = _model_edit_application_id(spec) + any(item -> _model_edit_application_id(item) == application_id, model.applications) && error( + "CompositeModel application `$(application_id)` already exists.", + ) + push!(model.applications, spec) + return model +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::RemoveModelApplication) + deleteat!(model.applications, _model_edit_application_index(model, edit.application_id)) + return model +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::RemoveModelTemplateApplication) + _, selected_instance = _model_edit_instance(model, edit.instance) + base_name = _model_edit_template_application_id(selected_instance, edit.application_id) + template = selected_instance.template + template_specs = Any[as_model_spec(item) for item in template.applications] + indexes = Int[ + index for (index, spec) in pairs(template_specs) + if _mounted_application_name(spec, index) == base_name + ] + index = only(indexes) + deleteat!(template_specs, index) + replacement_template = CompositeModelTemplate( + Tuple(template_specs); + kind=template.kind, + species=template.species, + parameters=template.parameters, + ) + instances = Any[] + for instance in model.instances + if instance.template === template + overrides = _model_edit_namedtuple_remove(instance.overrides, base_name) + object_overrides = Tuple( + override for override in instance.object_overrides + if override.application != base_name + ) + push!(instances, _model_edit_normalize_instance( + instance; + template=replacement_template, + overrides=overrides, + object_overrides=object_overrides, + )) + else + push!(instances, _model_edit_normalize_instance(instance)) + end + end + return _model_edit_rebuild_instances(model, Tuple(instances)) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::ReplaceModelApplicationModel) + spec = _model_edit_spec(model, edit.application_id) + _validate_model_override_contract!( + model_(spec), + edit.model; + description="Replacement model for application `$(edit.application_id)`", + ) + return _replace_model_edit_spec!( + model, + edit.application_id, + ModelSpec(spec; model=edit.model), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::UpdateModelApplication) + spec = _model_edit_spec(model, edit.application_id) + _validate_model_override_contract!( + model_(spec), + edit.model; + description="Updated model for application `$(edit.application_id)`", + ) + edit.selector isa AbstractObjectMultiplicity || error( + "Application targets must use One(...), OptionalOne(...), or Many(...).", + ) + if edit.name != edit.application_id + any(item -> _model_edit_application_id(item) == edit.name, model.applications) && error( + "CompositeModel application `$(edit.name)` already exists.", + ) + end + _replace_model_edit_spec!( + model, + edit.application_id, + ModelSpec( + spec; + model=edit.model, + name=edit.name, + applies_to=edit.selector, + timestep=edit.timestep, + ), + ) + edit.name == edit.application_id || _rewrite_model_application_references!( + model, + edit.application_id, + edit.name, + ) + return model +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::RenameModelApplication) + any(item -> _model_edit_application_id(item) == edit.name, model.applications) && error( + "CompositeModel application `$(edit.name)` already exists.", + ) + spec = _model_edit_spec(model, edit.application_id) + _replace_model_edit_spec!( + model, + edit.application_id, + ModelSpec(spec; name=edit.name), + ) + _rewrite_model_application_references!(model, edit.application_id, edit.name) + return model +end + +function _rewrite_model_selector_application(selector, old_id::Symbol, new_id::Symbol) + selector isa AbstractObjectMultiplicity || return selector + rewritten = (; ( + Symbol(key) => ( + Symbol(key) == :application && value == old_id ? new_id : value + ) + for (key, value) in pairs(criteria(selector)) + )...) + return _rebuild_selector(selector, rewritten) +end + +function _rewrite_model_application_references!(model::CompositeModel, old_id::Symbol, new_id::Symbol) + for (index, raw_spec) in pairs(model.applications) + spec = as_model_spec(raw_spec) + inputs = (; ( + Symbol(name) => _rewrite_model_selector_application(selector, old_id, new_id) + for (name, selector) in pairs(spec.inputs) + )...) + calls = (; ( + Symbol(name) => _rewrite_model_selector_application(selector, old_id, new_id) + for (name, selector) in pairs(spec.calls) + )...) + target = _rewrite_model_selector_application(spec.applies_to, old_id, new_id) + updates_ = Tuple( + Updates( + _update_variables(update)...; + after=Tuple(item == old_id ? new_id : item for item in _update_after(update)), + ) + for update in spec.updates + ) + model.applications[index] = ModelSpec( + spec; + inputs=inputs, + calls=calls, + applies_to=target, + updates=updates_, + ) + end + return model +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::SetModelApplicationTargets) + edit.selector isa AbstractObjectMultiplicity || error( + "Application targets must use One(...), OptionalOne(...), or Many(...).", + ) + spec = _model_edit_spec(model, edit.application_id) + return _replace_model_edit_spec!( + model, + edit.application_id, + ModelSpec(spec; applies_to=edit.selector), + ) +end + +function _model_edit_namedtuple_set(values::NamedTuple, name::Symbol, value) + pairs_ = Pair{Symbol,Any}[ + Symbol(key) => (Symbol(key) == name ? value : item) + for (key, item) in pairs(values) + ] + name in Symbol.(keys(values)) || push!(pairs_, name => value) + return (; pairs_...) +end + +function _model_edit_namedtuple_remove(values::NamedTuple, name::Symbol) + return (; ( + Symbol(key) => item + for (key, item) in pairs(values) + if Symbol(key) != name + )...) +end + +function _model_edit_origins_set(origins::NamedTuple, name::Symbol, origin::Symbol) + return _model_edit_namedtuple_set(origins, name, origin) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::SetModelInputBinding) + edit.selector isa AbstractObjectMultiplicity || error("An input binding requires an object selector.") + spec = _model_edit_spec(model, edit.application_id) + edit.input in Symbol.(keys(inputs_(spec))) || error( + "Application `$(edit.application_id)` model has no input `$(edit.input)`.", + ) + inputs = _model_edit_namedtuple_set(spec.inputs, edit.input, edit.selector) + origins = _model_edit_origins_set(spec.input_origins, edit.input, :model_spec) + return _replace_model_edit_spec!( + model, + edit.application_id, + ModelSpec(spec; inputs=inputs, input_origins=origins), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::RemoveModelInputBinding) + spec = _model_edit_spec(model, edit.application_id) + inputs = _model_edit_namedtuple_remove(spec.inputs, edit.input) + origins = _model_edit_namedtuple_remove(spec.input_origins, edit.input) + return _replace_model_edit_spec!( + model, + edit.application_id, + ModelSpec(spec; inputs=inputs, input_origins=origins), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::SetModelCallBinding) + edit.selector isa AbstractObjectMultiplicity || error("A call binding requires an object selector.") + spec = _model_edit_spec(model, edit.application_id) + calls = _model_edit_namedtuple_set(spec.calls, edit.call, edit.selector) + origins = _model_edit_origins_set(spec.call_origins, edit.call, :model_spec) + return _replace_model_edit_spec!( + model, + edit.application_id, + ModelSpec(spec; calls=calls, call_origins=origins), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::RemoveModelCallBinding) + spec = _model_edit_spec(model, edit.application_id) + calls = _model_edit_namedtuple_remove(spec.calls, edit.call) + origins = _model_edit_namedtuple_remove(spec.call_origins, edit.call) + return _replace_model_edit_spec!( + model, + edit.application_id, + ModelSpec(spec; calls=calls, call_origins=origins), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::SetModelApplicationTimeStep) + spec = _model_edit_spec(model, edit.application_id) + return _replace_model_edit_spec!( + model, + edit.application_id, + ModelSpec(spec; timestep=edit.timestep), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::SetModelApplicationEnvironment) + spec = _model_edit_spec(model, edit.application_id) + return _replace_model_edit_spec!( + model, + edit.application_id, + ModelSpec(spec; environment=edit.configuration), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::SetModelOutputRouting) + edit.route in (:canonical, :stream_only) || error( + "Output route must be `:canonical` or `:stream_only`.", + ) + spec = _model_edit_spec(model, edit.application_id) + edit.output in Symbol.(keys(outputs_(spec))) || error( + "Application `$(edit.application_id)` model has no output `$(edit.output)`.", + ) + routing = _model_edit_namedtuple_set(spec.output_routing, edit.output, edit.route) + return _replace_model_edit_spec!( + model, + edit.application_id, + ModelSpec(spec; output_routing=routing), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::SetModelUpdateOrdering) + spec = _model_edit_spec(model, edit.application_id) + return _replace_model_edit_spec!( + model, + edit.application_id, + ModelSpec(spec; updates=edit.updates), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::MarkModelPreviousTimeStep) + spec = _model_edit_spec(model, edit.application_id) + selector = if haskey(spec.inputs, edit.input) + getproperty(spec.inputs, edit.input) + else + report = compile_model_report(model) + selectors = Any[ + binding.selector for binding in report.input_bindings + if binding.application_id == edit.application_id && binding.input == edit.input + ] + isempty(selectors) && error( + "Application `$(edit.application_id)` has no resolved selector for input `$(edit.input)`. Add an input binding first.", + ) + first(selectors) + end + selector isa AbstractObjectMultiplicity || error( + "Input `$(edit.input)` on application `$(edit.application_id)` does not use an object selector.", + ) + previous_selector = _selector_with_previous_timestep( + selector, + PreviousTimeStep(edit.input), + ) + return _apply_model_graph_edit!( + model, + SetModelInputBinding(edit.application_id, edit.input, previous_selector), + ) +end + +function _model_selector_without_previous_timestep(selector::AbstractObjectMultiplicity) + values = (; ( + Symbol(key) => value + for (key, value) in pairs(criteria(selector)) + if Symbol(key) != :policy + )...) + return _rebuild_selector(selector, values) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::UnmarkModelPreviousTimeStep) + spec = _model_edit_spec(model, edit.application_id) + haskey(spec.inputs, edit.input) || error( + "Application `$(edit.application_id)` has no input binding `$(edit.input)`.", + ) + selector = getproperty(spec.inputs, edit.input) + _selector_policy(selector) isa PreviousTimeStep || error( + "Input `$(edit.input)` on application `$(edit.application_id)` is not marked PreviousTimeStep.", + ) + return _apply_model_graph_edit!( + model, + SetModelInputBinding( + edit.application_id, + edit.input, + _model_selector_without_previous_timestep(selector), + ), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::BreakModelCycle) + _apply_model_graph_edit!( + model, + MarkModelPreviousTimeStep(edit.application_id, edit.input), + ) + edit.initialize_missing || return model + report = compile_model_report(model) + applications = [ + application for application in report.applications + if application.id == edit.application_id + ] + isempty(applications) && error( + "Application `$(edit.application_id)` could not be resolved after breaking its cycle.", + ) + application = only(applications) + for object_id in application.target_ids + object = _model_object(model, object_id) + supplied = object.status isa Status && edit.input in Symbol.(propertynames(object.status)) + supplied || _set_model_object_status!(model, object_id, edit.input, edit.initial_value) + end + return model +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::AddModelObject) + register_object!(model, edit.object) + return model +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::RemoveModelObject) + remove_object!(model, edit.object_id; recursive=edit.recursive) + return model +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::ReparentModelObject) + reparent_object!(model, edit.object_id, edit.parent_id) + return model +end + +function _model_edit_status_values(status) + status isa Status || return Pair{Symbol,Any}[] + return Pair{Symbol,Any}[ + Symbol(name) => status[name] + for name in propertynames(status) + ] +end + +function _set_model_object_status!(model, object_id, variable, value) + object = _model_object(model, object_id) + values = _model_edit_status_values(object.status) + index = findfirst(pair -> first(pair) == variable, values) + if isnothing(index) + push!(values, variable => value) + else + values[index] = variable => value + end + object.status = Status((; values...)) + return model +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::SetModelObjectStatus) + return _set_model_object_status!(model, edit.object_id, edit.variable, edit.value) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::SetModelObjectStatuses) + isempty(edit.object_ids) && error("SetModelObjectStatuses requires at least one object id.") + for object_id in edit.object_ids + _set_model_object_status!(model, object_id, edit.variable, edit.value) + end + return model +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::RemoveModelObjectStatus) + object = _model_object(model, edit.object_id) + values = Pair{Symbol,Any}[ + pair for pair in _model_edit_status_values(object.status) + if first(pair) != edit.variable + ] + object.status = isempty(values) ? nothing : Status((; values...)) + return model +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::SetModelObjectMetadata) + object = _model_object(model, edit.object_id) + allowed = Set((:scale, :kind, :species, :name, :geometry, :parent)) + unknown = setdiff(Set(Symbol.(keys(edit.configuration))), allowed) + isempty(unknown) || error("Unsupported object metadata fields: $(sort!(collect(unknown); by=string)).") + _deindex_object!(model.registry, object) + for (key_, value) in pairs(edit.configuration) + key = Symbol(key_) + if key == :parent + continue + elseif key == :geometry + object.geometry = value + else + setfield!(object, key, isnothing(value) ? nothing : Symbol(value)) + end + end + _index_object!(model.registry, object) + haskey(edit.configuration, :parent) && reparent_object!(model, object.id, edit.configuration.parent) + _mark_environment_bindings_dirty!(model, object.id) + return model +end + +function _model_edit_instance(model::CompositeModel, name::Symbol) + matches = findall(instance -> instance.name == name, model.instances) + isempty(matches) && error("CompositeModel has no object instance `$(name)`.") + length(matches) == 1 || error("CompositeModel object instance name `$(name)` is ambiguous.") + return only(matches), model.instances[only(matches)] +end + +function _model_edit_template_application_id(instance::ObjectInstance, application_id::Symbol) + prefix = string(instance.name, "__") + candidate = startswith(string(application_id), prefix) ? + Symbol(chopprefix(string(application_id), prefix)) : application_id + specs = Tuple(as_model_spec(item) for item in instance.template.applications) + matches = Symbol[ + _mounted_application_name(spec, index) + for (index, spec) in pairs(specs) + if candidate in (_mounted_application_name(spec, index), process(spec)) + ] + isempty(matches) && error( + "Instance `$(instance.name)` template has no application matching `$(application_id)`.", + ) + length(matches) == 1 || error( + "Instance `$(instance.name)` application `$(application_id)` is ambiguous; use its template application name.", + ) + return only(matches) +end + +function _model_edit_template_application_spec(instance::ObjectInstance, application_id::Symbol) + base_name = _model_edit_template_application_id(instance, application_id) + specs = Tuple(as_model_spec(item) for item in instance.template.applications) + matches = [ + spec for (index, spec) in pairs(specs) + if _mounted_application_name(spec, index) == base_name + ] + return base_name, only(matches) +end + +function _model_edit_normalize_instance( + instance::ObjectInstance; + template=instance.template, + overrides=instance.overrides, + object_overrides=instance.object_overrides, +) + return ObjectInstance( + instance.name, + template; + root=_instance_root_id(instance), + overrides=overrides, + object_overrides=object_overrides, + ) +end + +function _model_edit_rebuild_instances( + model::CompositeModel, + instances; + replace_mounted_ids=Set{Symbol}(), +) + instances = Tuple(_model_edit_normalize_instance(instance) for instance in instances) + mounted_ids = Set{Symbol}() + for instance in model.instances + union!(mounted_ids, _instance_application_ids(model, instance)) + end + global_applications = Any[ + application for application in model.applications + if _model_edit_application_id(application) ∉ mounted_ids + ] + old_mounted = Dict( + _model_edit_application_id(application) => as_model_spec(application) + for application in model.applications + if _model_edit_application_id(application) in mounted_ids + ) + rebuilt = CompositeModel( + (deepcopy(object) for object in model_objects(model))...; + applications=global_applications, + instances=instances, + environment=model.environment, + source_adapter=model.source_adapter, + ) + for (index, application) in pairs(rebuilt.applications) + application_id = _model_edit_application_id(application) + haskey(old_mounted, application_id) || continue + application_id in replace_mounted_ids && continue + old_spec = old_mounted[application_id] + new_spec = as_model_spec(application) + rebuilt.applications[index] = ModelSpec(old_spec; model=model_(new_spec)) + end + return rebuilt +end + + +function _apply_model_graph_edit!(model::CompositeModel, edit::UpdateModelTemplateApplication) + _, selected_instance = _model_edit_instance(model, edit.instance) + base_name = _model_edit_template_application_id(selected_instance, edit.application_id) + template = selected_instance.template + template_specs = Any[as_model_spec(item) for item in template.applications] + matches = Int[ + index for (index, spec) in pairs(template_specs) + if _mounted_application_name(spec, index) == base_name + ] + isempty(matches) && error("Template application `$(base_name)` was not found.") + index = only(matches) + original = template_specs[index] + _validate_model_override_contract!( + model_(original), + edit.model; + description="Updated shared template application `$(base_name)`", + ) + edit.selector isa AbstractObjectMultiplicity || error( + "Template application targets must use One(...), OptionalOne(...), or Many(...).", + ) + template_specs[index] = ModelSpec( + original; + model=edit.model, + applies_to=edit.selector, + timestep=edit.timestep, + ) + replacement_template = CompositeModelTemplate( + Tuple(template_specs); + kind=template.kind, + species=template.species, + parameters=template.parameters, + ) + affected = Set{Symbol}() + instances = Any[] + for instance in model.instances + if instance.template === template + push!(affected, Symbol(instance.name, "__", base_name)) + push!(instances, _model_edit_normalize_instance(instance; template=replacement_template)) + else + push!(instances, _model_edit_normalize_instance(instance)) + end + end + return _model_edit_rebuild_instances( + model, + Tuple(instances); + replace_mounted_ids=affected, + ) +end + +function _model_edit_replace_instance(model::CompositeModel, instance_name::Symbol, replacement) + index, _ = _model_edit_instance(model, instance_name) + instances = Any[model.instances...] + instances[index] = replacement + return _model_edit_rebuild_instances(model, Tuple(instances)) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::SetModelInstanceOverride) + _, instance = _model_edit_instance(model, edit.instance) + application, spec = _model_edit_template_application_spec(instance, edit.application_id) + _validate_model_override_contract!( + model_(spec), + edit.model; + description="Instance override for `$(edit.instance)` application `$(application)`", + ) + overrides = _model_edit_namedtuple_set(instance.overrides, application, edit.model) + return _model_edit_replace_instance( + model, + edit.instance, + _model_edit_normalize_instance(instance; overrides=overrides), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::RemoveModelInstanceOverride) + _, instance = _model_edit_instance(model, edit.instance) + application = _model_edit_template_application_id(instance, edit.application_id) + haskey(instance.overrides, application) || error( + "Instance `$(edit.instance)` has no override for application `$(application)`.", + ) + overrides = _model_edit_namedtuple_remove(instance.overrides, application) + return _model_edit_replace_instance( + model, + edit.instance, + _model_edit_normalize_instance(instance; overrides=overrides), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::SetModelObjectOverride) + _, instance = _model_edit_instance(model, edit.instance) + application, spec = _model_edit_template_application_spec(instance, edit.application_id) + _validate_model_override_contract!( + model_(spec), + edit.model; + description="Object override for `$(edit.object_id.value)` application `$(application)`", + ) + object_ids = Set(_instance_object_ids(model, instance)) + edit.object_id in object_ids || error( + "Object `$(edit.object_id.value)` does not belong to instance `$(edit.instance)`.", + ) + overrides = Override[ + override for override in instance.object_overrides + if !(override.object == edit.object_id && override.application == application) + ] + push!(overrides, Override(object=edit.object_id, application=application, model=edit.model)) + return _model_edit_replace_instance( + model, + edit.instance, + _model_edit_normalize_instance(instance; object_overrides=Tuple(overrides)), + ) +end + +function _apply_model_graph_edit!(model::CompositeModel, edit::RemoveModelObjectOverride) + _, instance = _model_edit_instance(model, edit.instance) + application = _model_edit_template_application_id(instance, edit.application_id) + overrides = Override[ + override for override in instance.object_overrides + if !(override.object == edit.object_id && override.application == application) + ] + length(overrides) < length(instance.object_overrides) || error( + "Instance `$(edit.instance)` has no object override for `$(edit.object_id.value)` and application `$(application)`.", + ) + return _model_edit_replace_instance( + model, + edit.instance, + _model_edit_normalize_instance(instance; object_overrides=Tuple(overrides)), + ) +end + +abstract type AbstractModelGraphEditorSession end + +function _model_graph_editor_missing_http() + throw(ArgumentError( + "Interactive CompositeModel graph editing requires HTTP.jl. Load it with `using HTTP` before calling `edit_graph`.", + )) +end + +""" + edit_graph([model]; kwargs...) + +Start the HTTP-backed interactive CompositeModel editor. Call `edit_graph()` to begin +with an empty CompositeModel. The implementation is provided by the HTTP package +extension. +""" +edit_graph(args...; kwargs...) = _model_graph_editor_missing_http() + +current_model(::AbstractModelGraphEditorSession) = _model_graph_editor_missing_http() +apply_edit!(::AbstractModelGraphEditorSession, ::AbstractModelGraphEdit) = + _model_graph_editor_missing_http() +undo!(::AbstractModelGraphEditorSession) = _model_graph_editor_missing_http() +redo!(::AbstractModelGraphEditorSession) = _model_graph_editor_missing_http() diff --git a/src/visualization/model_graph_view.jl b/src/visualization/model_graph_view.jl new file mode 100644 index 000000000..70917f584 --- /dev/null +++ b/src/visualization/model_graph_view.jl @@ -0,0 +1,1319 @@ +""" + ModelGraphDiagnostic + +Structured diagnostic emitted while compiling a model for visualization. A +graph report keeps diagnostics attached to stable application, object, and +variable identities so an editor can present actionable controls. +""" +struct ModelGraphDiagnostic + code::Symbol + severity::Symbol + message::String + phase::Symbol + application_ids::Vector{Symbol} + object_ids::Vector{Any} + variable::Union{Nothing,Symbol} + suggestions::Vector{String} +end + +""" + CompositeModelCompilationReport + +Best-effort result of compiling a `CompositeModel` for inspection. Unlike +[`compile_composite_model`](@ref), this representation preserves successful earlier +phases when a later phase reports an invalid selector, binding, writer, or +cycle. +""" +struct CompositeModelCompilationReport + model::CompositeModel + initial_status_variables::Dict{ObjectId,Set{Symbol}} + applications::Any + input_bindings::Any + call_bindings::Any + application_order::Vector{Symbol} + dependency_children::Dict{Symbol,Set{Symbol}} + cycles::Vector{Vector{Symbol}} + diagnostics::Vector{ModelGraphDiagnostic} + compiled::Any +end + +""" + ModelGraphView + +JSON-oriented, renderer-independent representation of a Composite Model/Object graph. +Applications are the default visual unit; `executions` optionally expands them +into concrete `(application, object)` targets. +""" +struct ModelGraphView + level::Symbol + metadata::Dict{String,Any} + objects::Vector{Dict{String,Any}} + instances::Vector{Dict{String,Any}} + applications::Vector{Dict{String,Any}} + executions::Vector{Dict{String,Any}} + edges::Vector{Dict{String,Any}} + model_library::Vector{Dict{String,Any}} + initialization::Vector{Dict{String,Any}} + diagnostics::Vector{Dict{String,Any}} + cycles::Vector{Dict{String,Any}} + available_actions::Vector{String} +end + +function _model_graph_diagnostic( + err, + phase::Symbol; + code=Symbol(phase, :_error), + severity=:error, + application_ids=Symbol[], + object_ids=Any[], + variable=nothing, + suggestions=String[], +) + return ModelGraphDiagnostic( + Symbol(code), + Symbol(severity), + sprint(showerror, err), + phase, + Symbol[Symbol(id) for id in application_ids], + Any[object_ids...], + isnothing(variable) ? nothing : Symbol(variable), + String[String(item) for item in suggestions], + ) +end + +function _model_graph_phase!(operation, diagnostics, phase::Symbol, fallback) + try + return operation() + catch err + push!(diagnostics, _model_graph_diagnostic(err, phase)) + return fallback + end +end + +function _model_graph_dependency_children(applications, input_bindings, call_bindings) + children = Dict{Symbol,Set{Symbol}}() + call_owners = _model_call_owners(call_bindings) + _model_input_order_edges!(children, input_bindings, call_owners) + _model_update_order_edges!(children, applications) + return children +end + +function _model_graph_cycle_components(applications, children) + application_ids = Symbol[application.id for application in applications] + index = Ref(0) + indexes = Dict{Symbol,Int}() + lowlinks = Dict{Symbol,Int}() + stack = Symbol[] + on_stack = Set{Symbol}() + components = Vector{Vector{Symbol}}() + + function visit(application_id::Symbol) + index[] += 1 + indexes[application_id] = index[] + lowlinks[application_id] = index[] + push!(stack, application_id) + push!(on_stack, application_id) + + for child in sort!(collect(get(children, application_id, Set{Symbol}())); by=string) + if !haskey(indexes, child) + visit(child) + lowlinks[application_id] = min(lowlinks[application_id], lowlinks[child]) + elseif child in on_stack + lowlinks[application_id] = min(lowlinks[application_id], indexes[child]) + end + end + + lowlinks[application_id] == indexes[application_id] || return + component = Symbol[] + while !isempty(stack) + member = pop!(stack) + delete!(on_stack, member) + push!(component, member) + member == application_id && break + end + if length(component) > 1 || application_id in get(children, application_id, Set{Symbol}()) + sort!(component; by=string) + push!(components, component) + end + end + + for application_id in application_ids + haskey(indexes, application_id) || visit(application_id) + end + sort!(components; by=component -> join(string.(component), ":")) + return components +end + +function _model_graph_compiled( + model, + applications, + input_bindings, + call_bindings, + application_order, + diagnostics, +) + isempty(diagnostics) || return nothing + applications_by_id = Dict(application.id => application for application in applications) + input_by_target = _index_model_bindings(input_bindings, :application_id, :consumer_id) + call_by_target = _index_model_bindings(call_bindings, :application_id, :consumer_id) + model_bundles = _compile_model_model_bundles( + applications, + applications_by_id, + call_by_target, + ) + return CompiledCompositeModel( + model, + applications, + applications_by_id, + _applications_by_object(applications), + input_bindings, + call_bindings, + input_by_target, + call_by_target, + _index_dynamic_input_bindings(model, input_bindings), + model_bundles, + application_order, + _model_timeline(model), + model.revision, + ) +end + +function _model_graph_fallback_application(model, raw_spec, timeline) + spec = as_model_spec(raw_spec) + selector = applies_to(spec) + selector isa AbstractObjectMultiplicity || error( + "Model application for process `$(process(spec))` has no valid `AppliesTo(...)` selector.", + ) + application_id = something(application_name(spec), process(spec)) + target_ids = resolve_object_ids(model, selector) + return CompiledModelApplication( + application_id, + spec, + process(spec), + application_name(spec), + target_ids, + selector, + timestep(spec), + _model_application_clock(model, spec, target_ids, timeline), + _compiled_object_model_overrides(spec, target_ids, application_id), + ) +end + +function _model_graph_compile_applications(model, timeline, diagnostics) + applications = _model_graph_phase!(diagnostics, :applications, CompiledModelApplication[]) do + _compile_model_applications(model, Tuple(model.applications), timeline) + end + isempty(applications) || return applications + isempty(model.applications) && return applications + + recovered = CompiledModelApplication[] + recovered_ids = Set{Symbol}() + for raw_spec in model.applications + try + application = _model_graph_fallback_application(model, raw_spec, timeline) + application.id in recovered_ids && error( + "Duplicate recovered model application id `$(application.id)`.", + ) + push!(recovered_ids, application.id) + push!(recovered, application) + catch err + spec = as_model_spec(raw_spec) + application_id = something(application_name(spec), process(spec)) + push!(diagnostics, _model_graph_diagnostic( + err, + :application_recovery; + application_ids=[application_id], + suggestions=["Fix the application selector or authored configuration."], + )) + end + end + return recovered +end + +""" + compile_model_report(model; strict=false) + +Compile a Composite model for visualization while retaining partial graph information +and structured diagnostics. With `strict=true`, call the simulation compiler +and propagate its errors unchanged. +""" +function compile_model_report(model::CompositeModel; strict::Bool=false) + # Compilation wires reference carriers and prepares generated status fields. + # A viewer must not mutate the user's editable or pre-run Composite model merely by + # inspecting it, so all diagnostic compilation happens on a structural copy. + initial_status_variables = Dict( + object.id => Set{Symbol}( + object.status isa Status ? Symbol.(propertynames(object.status)) : Symbol[], + ) + for object in values(model.registry.objects) + ) + model = deepcopy(model) + if strict + compiled = compile_composite_model(model) + children = _model_graph_dependency_children( + compiled.applications, + compiled.input_bindings, + compiled.call_bindings, + ) + return CompositeModelCompilationReport( + model, + initial_status_variables, + compiled.applications, + compiled.input_bindings, + compiled.call_bindings, + Symbol[compiled.application_order...], + children, + Vector{Vector{Symbol}}(), + ModelGraphDiagnostic[], + compiled, + ) + end + + diagnostics = ModelGraphDiagnostic[] + timeline = _model_graph_phase!(diagnostics, :timeline, nothing) do + _model_timeline(model) + end + isnothing(timeline) && return CompositeModelCompilationReport( + model, + initial_status_variables, + CompiledModelApplication[], + CompiledModelInputBinding[], + CompiledModelCallBinding[], + Symbol[], + Dict{Symbol,Set{Symbol}}(), + Vector{Vector{Symbol}}(), + diagnostics, + nothing, + ) + + applications = _model_graph_compile_applications(model, timeline, diagnostics) + isempty(applications) && !isempty(model.applications) && return CompositeModelCompilationReport( + model, + initial_status_variables, + applications, + CompiledModelInputBinding[], + CompiledModelCallBinding[], + Symbol[], + Dict{Symbol,Set{Symbol}}(), + Vector{Vector{Symbol}}(), + diagnostics, + nothing, + ) + + call_bindings = _model_graph_phase!(diagnostics, :calls, CompiledModelCallBinding[]) do + _compile_model_call_bindings(model, applications) + end + _model_graph_phase!(diagnostics, :call_cadence, nothing) do + _validate_model_call_cadences!(applications, call_bindings, timeline) + end + _model_graph_phase!(diagnostics, :writers, nothing) do + _validate_model_writers!(applications, call_bindings) + end + _model_graph_phase!(diagnostics, :output_status, nothing) do + _prepare_model_output_statuses!(model, applications) + end + + input_bindings = _model_graph_phase!(diagnostics, :inputs, CompiledModelInputBinding[]) do + _compile_model_input_bindings( + model, + applications, + _manual_call_application_ids(call_bindings), + ) + end + _model_graph_phase!(diagnostics, :input_status, nothing) do + _prepare_model_bound_input_statuses!(model, applications, input_bindings) + _wire_model_input_carriers!(model, input_bindings) + end + + children = Dict{Symbol,Set{Symbol}}() + _model_graph_phase!(diagnostics, :dependency_inputs, nothing) do + call_owners = _model_call_owners(call_bindings) + _model_input_order_edges!(children, input_bindings, call_owners) + end + _model_graph_phase!(diagnostics, :update_order, nothing) do + _model_update_order_edges!(children, applications) + end + cycles = _model_graph_cycle_components(applications, children) + application_order = Symbol[] + if isempty(cycles) + application_order = _model_graph_phase!(diagnostics, :schedule, Symbol[]) do + _stable_topological_application_order(applications, children) + end + else + for cycle in cycles + message = "Composite model application dependency cycle detected among applications `$(cycle)`." + push!(diagnostics, ModelGraphDiagnostic( + :application_cycle, + :error, + message, + :schedule, + copy(cycle), + Any[], + nothing, + [ + "Mark an eligible input as PreviousTimeStep to use its previous accepted value.", + "Revise Inputs(...) or Updates(...) to remove the same-step dependency.", + ], + )) + end + end + + compiled = try + _model_graph_compiled( + model, + applications, + input_bindings, + call_bindings, + application_order, + diagnostics, + ) + catch err + push!(diagnostics, _model_graph_diagnostic(err, :model_bundles)) + nothing + end + + return CompositeModelCompilationReport( + model, + initial_status_variables, + applications, + input_bindings, + call_bindings, + application_order, + children, + cycles, + diagnostics, + compiled, + ) +end + +_model_graph_object_id(value) = value isa ObjectId ? value.value : value +_model_graph_application_node_id(id) = string("application:", id) +_model_graph_object_node_id(id) = string("object:", _model_graph_object_id(id)) +_model_graph_instance_node_id(name) = string("instance:", name) +_model_graph_execution_node_id(application_id, object_id) = + string("execution:", application_id, ":", _model_graph_object_id(object_id)) +_model_graph_port_id(application_id, role, variable) = + string(_model_graph_application_node_id(application_id), ":", role, ":", variable) + +function _model_graph_json_value(value) + value === nothing && return nothing + value === missing && return nothing + value isa Bool && return value + if value isa Real + return isfinite(value) ? value : string(value) + end + value isa AbstractString && return String(value) + value isa Symbol && return string(value) + value isa ObjectId && return _model_graph_json_value(value.value) + value isa Type && return string(value) + value isa Module && return string(value) + value isa Pair && return Dict( + "first" => _model_graph_json_value(first(value)), + "second" => _model_graph_json_value(last(value)), + ) + if value isa NamedTuple + return Dict(string(key) => _model_graph_json_value(item) for (key, item) in pairs(value)) + end + if value isa AbstractDict + return Dict(string(key) => _model_graph_json_value(item) for (key, item) in pairs(value)) + end + if value isa Tuple || value isa AbstractArray || value isa AbstractSet + return [_model_graph_json_value(item) for item in value] + end + value isa AbstractObjectMultiplicity && return _model_graph_selector_dict(value) + return string(value) +end + +function _model_graph_selector_atom(selector::AbstractObjectSelector) + descriptor = Dict{String,Any}("type" => string(nameof(typeof(selector)))) + selector isa Ancestor && (descriptor["scale"] = _model_graph_json_value(selector.scale)) + selector isa Scope && (descriptor["name"] = string(selector.name)) + selector isa Kind && (descriptor["kind"] = string(selector.kind)) + selector isa Species && (descriptor["species"] = string(selector.species)) + selector isa Scale && (descriptor["scale"] = string(selector.scale)) + selector isa Relation && (descriptor["relation"] = string(selector.relation)) + return descriptor +end + +function _model_graph_policy_dict(policy) + descriptor = Dict{String,Any}( + "type" => string(nameof(typeof(policy))), + "julia" => repr(policy), + ) + policy isa PreviousTimeStep && (descriptor["variable"] = string(policy.variable)) + policy isa PreviousTimeStep && (descriptor["process"] = string(policy.process)) + policy isa Interpolate && (descriptor["mode"] = string(policy.mode)) + policy isa Interpolate && (descriptor["extrapolation"] = string(policy.extrapolation)) + policy isa Union{Integrate,Aggregate} && (descriptor["reducer"] = repr(policy.reducer)) + return descriptor +end + +function _model_graph_selector_criteria(selector::AbstractObjectMultiplicity) + result = Dict{String,Any}() + for (key_, value) in pairs(criteria(selector)) + key = Symbol(key_) + result[string(key)] = if key == :selectors + [_model_graph_selector_atom(item) for item in value] + elseif key == :within && value isa AbstractObjectSelector + _model_graph_selector_atom(value) + elseif key == :policy + _model_graph_policy_dict(value) + else + _model_graph_json_value(value) + end + end + return result +end + +function _model_graph_selector_dict(selector::AbstractObjectMultiplicity) + return Dict{String,Any}( + "type" => string(nameof(typeof(selector))), + "multiplicity" => string(multiplicity(selector)), + "criteria" => _model_graph_selector_criteria(selector), + "julia" => repr(selector), + ) +end + +function _model_graph_model_parameters(model) + parameters = Dict{String,Any}() + for field in fieldnames(Base.unwrap_unionall(typeof(model))) + value = getfield(model, field) + parameters[string(field)] = Dict{String,Any}( + "value" => _model_graph_json_value(value), + "julia" => repr(value), + "type" => string(_parameter_choice_from_type(typeof(value))), + "juliaType" => string(typeof(value)), + ) + end + return parameters +end + +function _model_graph_port(application, role::Symbol, variable, default) + name = Symbol(variable) + return Dict{String,Any}( + "id" => _model_graph_port_id(application.id, role, name), + "name" => string(name), + "role" => string(role), + "default" => _model_graph_json_value(default), + "defaultJulia" => repr(default), + "expectedType" => string(typeof(default)), + ) +end + +function _model_graph_application_dict(composite_model, application) + process_model = _application_default_model(application) + spec = application.spec + inputs = inputs_(application.spec) + outputs = outputs_(application.spec) + environment_inputs = meteo_inputs_(application.spec) + environment_outputs = meteo_outputs_(application.spec) + target_objects = [_model_object(composite_model, id) for id in application.target_ids] + environment = environment_config(spec) + environment_payload = environment isa EnvironmentConfig ? environment.config : environment + return Dict{String,Any}( + "id" => _model_graph_application_node_id(application.id), + "applicationId" => string(application.id), + "name" => isnothing(application.name) ? nothing : string(application.name), + "process" => string(application.process), + "modelType" => string(typeof(process_model)), + "modelName" => string(nameof(typeof(process_model))), + "module" => string(parentmodule(typeof(process_model))), + "package" => _model_package_name(parentmodule(typeof(process_model))), + "modelParameters" => _model_graph_model_parameters(process_model), + "selector" => _model_graph_selector_dict(application.applies_to), + "targetIds" => [_model_graph_json_value(id.value) for id in application.target_ids], + "targetCount" => length(application.target_ids), + "targetScales" => sort!(unique!(String[string(object.scale) for object in target_objects if !isnothing(object.scale)])), + "targetKinds" => sort!(unique!(String[string(object.kind) for object in target_objects if !isnothing(object.kind)])), + "targetSpecies" => sort!(unique!(String[string(object.species) for object in target_objects if !isnothing(object.species)])), + "targetInstances" => sort!(unique!(String[ + string(instance) for id in application.target_ids + for instance in (_object_instance_name(composite_model, id),) + if !isnothing(instance) + ])), + "timestep" => _model_graph_json_value(application.timestep), + "clock" => _model_graph_json_value(application.clock), + "inputs" => [_model_graph_port(application, :input, name, value) for (name, value) in pairs(inputs)], + "outputs" => [_model_graph_port(application, :output, name, value) for (name, value) in pairs(outputs)], + "environmentInputs" => [_model_graph_port(application, :environment_input, name, value) for (name, value) in pairs(environment_inputs)], + "environmentOutputs" => [_model_graph_port(application, :environment_output, name, value) for (name, value) in pairs(environment_outputs)], + "inputBindings" => Dict( + string(name) => _model_graph_selector_dict(selector) + for (name, selector) in pairs(value_inputs(spec)) + ), + "callBindings" => Dict( + string(name) => _model_graph_selector_dict(selector) + for (name, selector) in pairs(model_calls(spec)) + ), + "environment" => _model_graph_json_value(environment_payload), + "meteoBindings" => _model_graph_json_value(meteo_bindings(spec)), + "meteoWindow" => _model_graph_json_value(meteo_window(spec)), + "outputRouting" => _model_graph_json_value(output_routing(spec)), + "updates" => [ + Dict( + "variables" => collect(string.(_update_variables(update))), + "after" => collect(string.(_update_after(update))), + ) + for update in updates(spec) + ], + "modelStorage" => isnothing(application.model_overrides) ? "shared_application" : "per_object_override", + "objectOverrides" => isnothing(application.model_overrides) ? Any[] : [ + Dict( + "objectId" => _model_graph_json_value(object_id.value), + "modelType" => string(typeof(override)), + "parameters" => _model_graph_model_parameters(override), + ) + for (object_id, override) in application.model_overrides + ], + ) +end + +function _model_graph_object_dict(row) + id = row.id + return Dict{String,Any}( + "id" => _model_graph_object_node_id(id), + "objectId" => _model_graph_json_value(id), + "scale" => _model_graph_json_value(row.scale), + "kind" => _model_graph_json_value(row.kind), + "species" => _model_graph_json_value(row.species), + "name" => _model_graph_json_value(row.name), + "instance" => _model_graph_json_value(row.instance), + "parent" => isnothing(row.parent) ? nothing : _model_graph_object_node_id(row.parent), + "children" => [_model_graph_object_node_id(child) for child in row.children], + "hasGeometry" => row.has_geometry, + "hasStatus" => row.has_status, + ) +end + +function _model_graph_instance_dict(row) + return Dict{String,Any}( + "id" => _model_graph_instance_node_id(row.name), + "name" => string(row.name), + "rootId" => _model_graph_json_value(row.root_id), + "kind" => _model_graph_json_value(row.kind), + "species" => _model_graph_json_value(row.species), + "objectIds" => [_model_graph_json_value(id) for id in row.object_ids], + "applicationIds" => string.(row.application_ids), + "instanceOverrides" => string.(row.instance_overrides), + "objectOverrides" => _model_graph_json_value(row.object_overrides), + "parametersType" => string(row.parameters_type), + ) +end + +function _model_graph_execution_dict(application, object_id) + model = _application_model(application, object_id) + return Dict{String,Any}( + "id" => _model_graph_execution_node_id(application.id, object_id), + "applicationId" => string(application.id), + "applicationNodeId" => _model_graph_application_node_id(application.id), + "objectId" => _model_graph_json_value(object_id.value), + "objectNodeId" => _model_graph_object_node_id(object_id), + "modelType" => string(typeof(model)), + "modelParameters" => _model_graph_model_parameters(model), + "overridden" => typeof(model) != typeof(_application_default_model(application)) || model != _application_default_model(application), + ) +end + +function _model_graph_application_for_object(applications_by_id, application_id, object_id) + application = get(applications_by_id, application_id, nothing) + isnothing(application) && return false + return object_id in application.target_ids +end + +function _model_graph_binding_edges(report, level) + edges = Dict{String,Dict{String,Any}}() + applications_by_id = Dict(application.id => application for application in report.applications) + cycle_memberships = Dict{Symbol,Int}() + for (index, component) in pairs(report.cycles) + for application_id in component + cycle_memberships[application_id] = index + end + end + + for binding in report.input_bindings + previous = binding.policy isa PreviousTimeStep + for source_application_id in binding.source_application_ids + source_ids = ObjectId[ + source_id for source_id in binding.source_ids + if _model_graph_application_for_object( + applications_by_id, + source_application_id, + source_id, + ) + ] + isempty(source_ids) && (source_ids = copy(binding.source_ids)) + if level == :resolved + for source_id in source_ids + edge_id = string( + "binding:", source_application_id, ":", source_id.value, + ":", binding.source_var, ":", binding.application_id, + ":", binding.consumer_id.value, ":", binding.input, + ) + edges[edge_id] = Dict{String,Any}( + "id" => edge_id, + "source" => _model_graph_execution_node_id(source_application_id, source_id), + "target" => _model_graph_execution_node_id(binding.application_id, binding.consumer_id), + "sourcePort" => _model_graph_port_id(source_application_id, :output, binding.source_var), + "targetPort" => _model_graph_port_id(binding.application_id, :input, binding.input), + "sourceVariable" => string(binding.source_var), + "targetVariable" => string(binding.input), + "sourceApplicationId" => string(source_application_id), + "targetApplicationId" => string(binding.application_id), + "sourceObjectIds" => [_model_graph_json_value(source_id.value)], + "targetObjectIds" => [_model_graph_json_value(binding.consumer_id.value)], + "kind" => previous ? "previous_timestep" : string(binding.origin == :inferred ? :inferred_same_object : :value_binding), + "projection" => "resolved", + "origin" => string(binding.origin), + "multiplicity" => string(binding.multiplicity), + "policy" => string(typeof(binding.policy)), + "selector" => _model_graph_selector_dict(binding.selector), + "cycle" => !previous && get(cycle_memberships, source_application_id, 0) == get(cycle_memberships, binding.application_id, -1), + ) + end + else + edge_id = string( + "binding:", source_application_id, ":", binding.source_var, + ":", binding.application_id, ":", binding.input, + ) + edge = get!(edges, edge_id) do + Dict{String,Any}( + "id" => edge_id, + "source" => _model_graph_application_node_id(source_application_id), + "target" => _model_graph_application_node_id(binding.application_id), + "sourcePort" => _model_graph_port_id(source_application_id, :output, binding.source_var), + "targetPort" => _model_graph_port_id(binding.application_id, :input, binding.input), + "sourceVariable" => string(binding.source_var), + "targetVariable" => string(binding.input), + "sourceApplicationId" => string(source_application_id), + "targetApplicationId" => string(binding.application_id), + "sourceObjectIds" => Any[], + "targetObjectIds" => Any[], + "kind" => previous ? "previous_timestep" : string(binding.origin == :inferred ? :inferred_same_object : :value_binding), + "projection" => "applications", + "origin" => string(binding.origin), + "multiplicity" => string(binding.multiplicity), + "policy" => string(typeof(binding.policy)), + "selector" => _model_graph_selector_dict(binding.selector), + "cycle" => !previous && get(cycle_memberships, source_application_id, 0) == get(cycle_memberships, binding.application_id, -1), + ) + end + append!(edge["sourceObjectIds"], [_model_graph_json_value(id.value) for id in source_ids]) + push!(edge["targetObjectIds"], _model_graph_json_value(binding.consumer_id.value)) + unique!(edge["sourceObjectIds"]) + unique!(edge["targetObjectIds"]) + end + end + end + return collect(values(edges)) +end + +function _model_graph_call_edges(report, level) + edges = Dict{String,Dict{String,Any}}() + for binding in report.call_bindings + for callee_application_id in binding.callee_application_ids + if level == :resolved + for callee_object_id in binding.callee_object_ids + edge_id = string( + "call:", binding.application_id, ":", binding.consumer_id.value, + ":", binding.call, ":", callee_application_id, ":", callee_object_id.value, + ) + edges[edge_id] = Dict{String,Any}( + "id" => edge_id, + "source" => _model_graph_execution_node_id(binding.application_id, binding.consumer_id), + "target" => _model_graph_execution_node_id(callee_application_id, callee_object_id), + "sourcePort" => nothing, + "targetPort" => nothing, + "kind" => "manual_call", + "projection" => "resolved", + "call" => string(binding.call), + "origin" => string(binding.origin), + "multiplicity" => string(binding.multiplicity), + "selector" => _model_graph_selector_dict(binding.selector), + "cycle" => false, + ) + end + else + edge_id = string("call:", binding.application_id, ":", binding.call, ":", callee_application_id) + edges[edge_id] = Dict{String,Any}( + "id" => edge_id, + "source" => _model_graph_application_node_id(binding.application_id), + "target" => _model_graph_application_node_id(callee_application_id), + "sourcePort" => nothing, + "targetPort" => nothing, + "kind" => "manual_call", + "projection" => "applications", + "call" => string(binding.call), + "origin" => string(binding.origin), + "multiplicity" => string(binding.multiplicity), + "selector" => _model_graph_selector_dict(binding.selector), + "cycle" => false, + ) + end + end + end + return collect(values(edges)) +end + +function _model_graph_update_edges(report) + application_ids = Set(application.id for application in report.applications) + edges = Dict{String,Any}[] + for application in report.applications + for update in updates(application.spec) + variables = _update_variables(update) + for predecessor in _update_after(update) + predecessor in application_ids || continue + edge_id = string( + "update:", predecessor, ":", application.id, ":", + join(string.(variables), ","), + ) + push!(edges, Dict{String,Any}( + "id" => edge_id, + "source" => _model_graph_application_node_id(predecessor), + "target" => _model_graph_application_node_id(application.id), + "sourceApplicationId" => string(predecessor), + "targetApplicationId" => string(application.id), + "variables" => collect(string.(variables)), + "kind" => "update_order", + "projection" => "applications", + "cycle" => false, + )) + end + end + end + return edges +end + +function _model_graph_environment_edges(report, level) + environment_bindings = try + _compile_environment_bindings_for_applications(report.model, report.applications) + catch + Any[] + end + if isempty(environment_bindings) + for application in report.applications + required_inputs = Symbol.(keys(meteo_inputs_(application.spec))) + produced_outputs = Symbol.(keys(meteo_outputs_(application.spec))) + isempty(required_inputs) && isempty(produced_outputs) && continue + source_inputs = _environment_source_variable_names(application.spec) + config = environment_config(application.spec) + provider = try + _environment_provider_from_config( + config, + _environment_backend_from_config(report.model, config), + ) + catch + :model + end + for object_id in application.target_ids + push!(environment_bindings, ( + application_id=application.id, + object_id=object_id, + provider=provider, + required_inputs=required_inputs, + source_inputs=source_inputs, + produced_outputs=produced_outputs, + )) + end + end + end + edges = Dict{String,Dict{String,Any}}() + for binding in environment_bindings + provider_id = string("environment:", binding.provider) + target_id = level == :resolved ? + _model_graph_execution_node_id(binding.application_id, binding.object_id) : + _model_graph_application_node_id(binding.application_id) + object_suffix = level == :resolved ? string(":", binding.object_id.value) : "" + for (target_variable, source_variable) in zip(binding.required_inputs, binding.source_inputs) + edge_id = string( + "environment-input:", binding.provider, ":", source_variable, ":", + binding.application_id, ":", target_variable, object_suffix, + ) + edge = get!(edges, edge_id) do + Dict{String,Any}( + "id" => edge_id, + "source" => provider_id, + "target" => target_id, + "sourcePort" => string(provider_id, ":output:", source_variable), + "targetPort" => _model_graph_port_id( + binding.application_id, + :environment_input, + target_variable, + ), + "sourceVariable" => string(source_variable), + "targetVariable" => string(target_variable), + "targetApplicationId" => string(binding.application_id), + "sourceObjectIds" => Any[], + "targetObjectIds" => Any[], + "provider" => string(binding.provider), + "kind" => "environment_binding", + "projection" => string(level), + "cycle" => false, + ) + end + push!(edge["targetObjectIds"], _model_graph_json_value(binding.object_id.value)) + unique!(edge["targetObjectIds"]) + end + for variable in binding.produced_outputs + edge_id = string( + "environment-output:", binding.application_id, ":", variable, ":", + binding.provider, object_suffix, + ) + edge = get!(edges, edge_id) do + Dict{String,Any}( + "id" => edge_id, + "source" => target_id, + "target" => provider_id, + "sourcePort" => _model_graph_port_id( + binding.application_id, + :environment_output, + variable, + ), + "targetPort" => string(provider_id, ":input:", variable), + "sourceVariable" => string(variable), + "targetVariable" => string(variable), + "sourceApplicationId" => string(binding.application_id), + "sourceObjectIds" => Any[], + "targetObjectIds" => Any[], + "provider" => string(binding.provider), + "kind" => "environment_binding", + "projection" => string(level), + "cycle" => false, + ) + end + push!(edge["sourceObjectIds"], _model_graph_json_value(binding.object_id.value)) + unique!(edge["sourceObjectIds"]) + end + end + return collect(values(edges)) +end + +function _model_graph_structure_edges(model, applications) + edges = Dict{String,Any}[] + for object in values(model.registry.objects) + if !isnothing(object.parent) + push!(edges, Dict{String,Any}( + "id" => string("topology:", object.parent.value, ":", object.id.value), + "source" => _model_graph_object_node_id(object.parent), + "target" => _model_graph_object_node_id(object.id), + "kind" => "object_topology", + "projection" => "topology", + "cycle" => false, + )) + end + end + for application in applications + for object_id in application.target_ids + push!(edges, Dict{String,Any}( + "id" => string("target:", application.id, ":", object_id.value), + "source" => _model_graph_application_node_id(application.id), + "target" => _model_graph_object_node_id(object_id), + "kind" => "application_target", + "projection" => "targets", + "cycle" => false, + )) + end + end + return edges +end + +function _model_graph_diagnostic_dict(diagnostic::ModelGraphDiagnostic) + return Dict{String,Any}( + "code" => string(diagnostic.code), + "severity" => string(diagnostic.severity), + "message" => diagnostic.message, + "phase" => string(diagnostic.phase), + "applicationIds" => string.(diagnostic.application_ids), + "objectIds" => _model_graph_json_value(diagnostic.object_ids), + "variable" => isnothing(diagnostic.variable) ? nothing : string(diagnostic.variable), + "suggestions" => diagnostic.suggestions, + ) +end + +function _model_graph_initialization(report) + supplied = report.initial_status_variables + bindings = Dict( + (binding.application_id, binding.consumer_id, binding.input) => binding + for binding in report.input_bindings + ) + environment_variables_ = try + environment_variables(environment_backend(report.model.environment)) + catch + Set{Symbol}() + end + isnothing(environment_variables_) && (environment_variables_ = nothing) + + rows = Dict{String,Any}[] + for application in report.applications + model_inputs = inputs_(application.spec) + model_outputs = outputs_(application.spec) + model_environment_inputs = meteo_inputs_(application.spec) + model_environment_outputs = meteo_outputs_(application.spec) + source_overrides = _environment_source_overrides(application.spec) + for object_id in application.target_ids + object = _model_object(report.model, object_id) + for (variable, default) in pairs(model_outputs) + push!(rows, _model_graph_initialization_row( + application.id, + object_id, + variable, + :output, + :generated, + default, + )) + end + for (variable, default) in pairs(model_environment_outputs) + push!(rows, _model_graph_initialization_row( + application.id, + object_id, + variable, + :environment_output, + :generated, + default, + )) + end + for (variable_, default) in pairs(model_inputs) + variable = Symbol(variable_) + binding = get(bindings, (application.id, object_id, variable), nothing) + status_supplied = variable in get(supplied, object_id, Set{Symbol}()) + disposition = if !isnothing(binding) && binding.policy isa PreviousTimeStep + status_supplied ? :supplied : :unresolved + elseif !isnothing(binding) + :producer_bound + elseif status_supplied + :supplied + else + :unresolved + end + value = disposition == :supplied ? object.status[variable] : default + row = _model_graph_initialization_row( + application.id, + object_id, + variable, + :input, + disposition, + value; + binding=binding, + ) + push!(rows, row) + end + for (variable_, default) in pairs(model_environment_inputs) + variable = Symbol(variable_) + source = Symbol(get(source_overrides, variable, variable)) + bound = isnothing(environment_variables_) || source in environment_variables_ + row = _model_graph_initialization_row( + application.id, + object_id, + variable, + :environment_input, + bound ? :environment_bound : :unresolved, + default, + ) + row["sourceVariable"] = string(source) + push!(rows, row) + end + end + end + sort!(rows; by=row -> ( + row["applicationId"], + string(row["objectId"]), + row["role"], + row["variable"], + )) + return rows +end + +function _model_graph_initialization_row( + application_id, + object_id, + variable, + role, + disposition, + value; + binding=nothing, +) + return Dict{String,Any}( + "applicationId" => string(application_id), + "objectId" => _model_graph_json_value(object_id.value), + "variable" => string(variable), + "role" => string(role), + "disposition" => string(disposition), + "value" => _model_graph_json_value(value), + "valueJulia" => repr(value), + "expectedType" => string(typeof(value)), + "sourceApplicationIds" => isnothing(binding) ? String[] : string.(binding.source_application_ids), + "sourceObjectIds" => isnothing(binding) ? Any[] : [_model_graph_json_value(id.value) for id in binding.source_ids], + "sourceVariable" => isnothing(binding) ? nothing : string(binding.source_var), + "origin" => isnothing(binding) ? string(disposition == :supplied ? :status : :missing) : string(binding.origin), + "previousTimeStep" => !isnothing(binding) && binding.policy isa PreviousTimeStep, + ) +end + +function _model_graph_cycle_dict(report, component, index) + members = Set(component) + dependency_edges = Dict{String,Any}[] + for source in component + for target in get(report.dependency_children, source, Set{Symbol}()) + target in members || continue + push!(dependency_edges, Dict{String,Any}( + "sourceApplicationId" => string(source), + "targetApplicationId" => string(target), + )) + end + end + break_candidates = Dict{String,Any}[] + for binding in report.input_bindings + binding.policy isa PreviousTimeStep && continue + binding.application_id in members || continue + any(source -> source in members, binding.source_application_ids) || continue + push!(break_candidates, Dict{String,Any}( + "applicationId" => string(binding.application_id), + "objectId" => _model_graph_json_value(binding.consumer_id.value), + "input" => string(binding.input), + "sourceApplicationIds" => string.(binding.source_application_ids), + "sourceObjectIds" => [_model_graph_json_value(id.value) for id in binding.source_ids], + "sourceVariable" => string(binding.source_var), + "selector" => _model_graph_selector_dict(binding.selector), + )) + end + return Dict{String,Any}( + "id" => string("cycle:", index), + "applicationIds" => string.(component), + "edges" => dependency_edges, + "breakCandidates" => break_candidates, + ) +end + +function _model_graph_model_library() + return [model_descriptor(type) for type in available_models()] +end + +function _normalize_model_graph_level(level) + normalized = Symbol(level) + normalized in (:applications, :topology, :resolved) || error( + "Unsupported model graph level `$(level)`. Use `:applications`, `:topology`, or `:resolved`.", + ) + return normalized +end + +""" + compile_model_graph(model; level=:applications, strict=false) + compile_model_graph(compiled::CompiledCompositeModel; level=:applications) + +Build a renderer-independent graph view from a Composite model or an existing compiled +model. +""" +function compile_model_graph(model::CompositeModel; level=:applications, strict::Bool=false) + return _model_graph_view(compile_model_report(model; strict=strict), level) +end + +function compile_model_graph(compiled::CompiledCompositeModel; level=:applications) + children = _model_graph_dependency_children( + compiled.applications, + compiled.input_bindings, + compiled.call_bindings, + ) + report = CompositeModelCompilationReport( + compiled.model, + Dict( + object.id => Set{Symbol}( + object.status isa Status ? Symbol.(propertynames(object.status)) : Symbol[], + ) + for object in values(compiled.model.registry.objects) + ), + compiled.applications, + compiled.input_bindings, + compiled.call_bindings, + Symbol[compiled.application_order...], + children, + _model_graph_cycle_components(compiled.applications, children), + ModelGraphDiagnostic[], + compiled, + ) + return _model_graph_view(report, level) +end + +function _model_graph_view(report::CompositeModelCompilationReport, level) + level = _normalize_model_graph_level(level) + objects = [_model_graph_object_dict(row) for row in explain_objects(report.model)] + instances = [_model_graph_instance_dict(row) for row in explain_instances(report.model)] + applications = [_model_graph_application_dict(report.model, application) for application in report.applications] + executions = [ + _model_graph_execution_dict(application, object_id) + for application in report.applications + for object_id in application.target_ids + ] + edges = vcat( + _model_graph_binding_edges(report, :applications), + _model_graph_binding_edges(report, :resolved), + _model_graph_call_edges(report, :applications), + _model_graph_call_edges(report, :resolved), + _model_graph_update_edges(report), + _model_graph_environment_edges(report, :applications), + _model_graph_environment_edges(report, :resolved), + _model_graph_structure_edges(report.model, report.applications), + ) + sort!(edges; by=edge -> edge["id"]) + initialization = _model_graph_initialization(report) + diagnostics = [_model_graph_diagnostic_dict(diagnostic) for diagnostic in report.diagnostics] + cycles = [ + _model_graph_cycle_dict(report, component, index) + for (index, component) in pairs(report.cycles) + ] + unresolved = count(row -> row["disposition"] == "unresolved", initialization) + metadata = Dict{String,Any}( + "title" => "PlantSimEngine CompositeModel Graph", + "modelRevision" => report.model.revision, + "objectCount" => length(objects), + "instanceCount" => length(instances), + "applicationCount" => length(applications), + "executionCount" => sum(application["targetCount"] for application in applications; init=0), + "bindingCount" => length(report.input_bindings), + "callCount" => length(report.call_bindings), + "unresolvedInitializationCount" => unresolved, + "cyclic" => !isempty(cycles), + "strictlyCompiled" => !isnothing(report.compiled), + ) + return ModelGraphView( + level, + metadata, + objects, + instances, + applications, + executions, + edges, + _model_graph_model_library(), + initialization, + diagnostics, + cycles, + [ + "inspect", + "filter", + "expand_executions", + "add_application", + "connect_binding", + "break_cycle", + ], + ) +end + +model_graph_view(scene_or_compiled; kwargs...) = compile_model_graph(scene_or_compiled; kwargs...) + +function _model_graph_view_dict(view::ModelGraphView) + return Dict{String,Any}( + "schemaVersion" => 1, + "level" => string(view.level), + "metadata" => view.metadata, + "objects" => view.objects, + "instances" => view.instances, + "applications" => view.applications, + "executions" => view.executions, + "edges" => view.edges, + "modelLibrary" => view.model_library, + "initialization" => view.initialization, + "diagnostics" => view.diagnostics, + "cycles" => view.cycles, + "availableActions" => view.available_actions, + ) +end + +model_graph_view_json(view::ModelGraphView) = + replace(JSON.json(_model_graph_view_dict(view)), " "<\\/") + +model_graph_view_json(scene_or_compiled; kwargs...) = + model_graph_view_json(model_graph_view(scene_or_compiled; kwargs...)) + +function _model_graph_assets_dir() + return normpath(joinpath(dirname(dirname(dirname(@__FILE__))), "frontend", "dist")) +end + +function _model_graph_vite_entry(assets_dir) + manifest_path = joinpath(assets_dir, ".vite", "manifest.json") + isfile(manifest_path) || return nothing + manifest = JSON.parse(read(manifest_path, String)) + for entry in values(manifest) + get(entry, "isEntry", false) == true && return entry + end + return get(manifest, "index.html", nothing) +end + +function _model_graph_react_html(view::ModelGraphView) + assets_dir = _model_graph_assets_dir() + entry = _model_graph_vite_entry(assets_dir) + isnothing(entry) && return nothing + js_file = get(entry, "file", nothing) + isnothing(js_file) && return nothing + css_files = get(entry, "css", Any[]) + js = read(joinpath(assets_dir, js_file), String) + css = join([read(joinpath(assets_dir, css_file), String) for css_file in css_files], "\n") + return _model_graph_html_document(view, css, js) +end + +function _model_graph_html_document(view, css, js) + json = model_graph_view_json(view) + html = raw""" + + + + + +PlantSimEngine CompositeModel Graph + + + + +
+ + + +""" + return replace( + html, + "__PSE_SCENE_GRAPH_JSON__" => json, + "__PSE_SCENE_GRAPH_CSS__" => css, + "__PSE_SCENE_GRAPH_JS__" => js, + ) +end + +function _model_graph_standalone_html(view::ModelGraphView) + css = raw""" +:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#2d2722;background:#f4efe6}*{box-sizing:border-box}body{margin:0}.shell{height:100vh;display:grid;grid-template-rows:auto 1fr}.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:14px 18px;background:#fffaf2;border-bottom:1px solid #dccfbd}.brand{font-weight:800;font-size:19px;margin-right:auto}.toolbar button,.toolbar input{border:1px solid #cdbfaa;background:#fffaf2;border-radius:6px;padding:8px 10px;color:inherit}.toolbar button.active{border-color:#1f7a5a;color:#155b43;background:#e9f4ed}.content{display:grid;grid-template-columns:1fr 300px;min-height:0}.canvas{position:relative;overflow:auto;padding:24px}.grid{position:relative;display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;z-index:2}.card{position:relative;background:#fffaf2;border:1px solid #ddcfbc;border-radius:7px;box-shadow:0 5px 18px #4a3e3020;padding:14px;min-height:150px;cursor:pointer}.card.cycle{border:2px solid #c94c3d}.card h3{margin:0 0 3px;font-size:16px}.muted{color:#776d64;font-size:12px}.badges{display:flex;gap:5px;flex-wrap:wrap;margin:10px 0}.badge{border:1px solid #d6c8b5;border-radius:999px;padding:2px 7px;font-size:11px}.ports{display:grid;grid-template-columns:1fr 1fr;gap:12px}.ports strong{font-size:10px;color:#776d64;text-transform:uppercase}.port{font-family:ui-monospace,monospace;font-size:11px;padding:3px 0}.inspector{overflow:auto;border-left:1px solid #dccfbd;background:#fffaf2;padding:18px}.inspector pre{white-space:pre-wrap;font-size:11px}.diagnostic{border-left:3px solid #c94c3d;padding:8px;margin:8px 0;background:#fff1eb}.empty{padding:60px;text-align:center;color:#776d64}@media(max-width:760px){.content{grid-template-columns:1fr}.inspector{display:none}.toolbar input{width:100%}} +""" + js = raw""" +const data=JSON.parse(document.getElementById('pse-model-graph-data').textContent);const root=document.getElementById('root');let mode=data.level||'applications';let query='';let selected=null;const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));function cards(){if(mode==='topology')return data.objects.map(o=>({...o,title:o.name||String(o.objectId),subtitle:[o.kind,o.scale,o.instance].filter(Boolean).join(' · '),inputs:[],outputs:[]}));if(mode==='resolved')return data.executions.map(e=>({...e,title:e.applicationId+' @ '+e.objectId,subtitle:e.modelType,inputs:[],outputs:[]}));return data.applications.map(a=>({...a,title:a.name||a.applicationId,subtitle:a.modelType}));}function render(){const items=cards().filter(item=>JSON.stringify(item).toLowerCase().includes(query.toLowerCase()));root.innerHTML=`
PlantSimEngine CompositeModel Graph
${items.length?`
${items.map(item=>{const appId=item.applicationId;const cyclic=data.cycles.some(c=>c.applicationIds.includes(appId));return `

${esc(item.title)}

${esc(item.subtitle)}
${item.targetCount!==undefined?`
${item.targetCount} targets${(item.targetScales||[]).map(v=>`${esc(v)}`).join('')}
Inputs${(item.inputs||[]).map(p=>`
${esc(p.name)}
`).join('')}
Outputs${(item.outputs||[]).map(p=>`
${esc(p.name)}
`).join('')}
`:''}
`}).join('')}
`:'
No matching graph items.
'}
`;root.querySelectorAll('[data-mode]').forEach(button=>button.onclick=()=>{mode=button.dataset.mode;selected=null;render()});root.querySelector('#search').oninput=event=>{query=event.target.value;render()};root.querySelectorAll('.card').forEach(card=>card.onclick=()=>{selected=cards().find(item=>item.id===card.dataset.id);render()});}render(); +""" + return _model_graph_html_document(view, css, js) +end + +function model_graph_view_html(view::ModelGraphView; renderer::Symbol=:react) + renderer == :standalone && return _model_graph_standalone_html(view) + renderer == :react || error("Unsupported renderer `$(renderer)`. Use `:react` or `:standalone`.") + html = _model_graph_react_html(view) + return isnothing(html) ? _model_graph_standalone_html(view) : html +end + +model_graph_view_html(scene_or_compiled; kwargs...) = + model_graph_view_html(model_graph_view(scene_or_compiled); kwargs...) + +""" + write_model_graph_view(path, scene_or_view; level=:applications, + strict=false, renderer=:react) + +Write a self-contained static Model graph viewer. The default renderer uses +the bundled frontend when available and otherwise falls back to the standalone +viewer. +""" +function write_model_graph_view( + path::AbstractString, + scene_or_view; + level=:applications, + strict::Bool=false, + renderer::Symbol=:react, +) + view = scene_or_view isa ModelGraphView ? + scene_or_view : + model_graph_view(scene_or_view; level=level, strict=strict) + full_path = abspath(path) + mkpath(dirname(full_path)) + write(full_path, model_graph_view_html(view; renderer=renderer)) + return full_path +end diff --git a/test/Project.toml b/test/Project.toml index f806effc7..52fe627ff 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,10 +1,11 @@ [deps] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" -BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" MultiScaleTreeGraph = "dd4a991b-8a45-4075-bede-262ee62d5583" PlantMeteo = "4630fe09-e0fb-4da5-a846-781cb73437b6" PlantSimEngine = "9a576370-710b-4269-adf9-4f603a9c6423" diff --git a/test/fixtures/graph_editor_e2e_server.jl b/test/fixtures/graph_editor_e2e_server.jl new file mode 100644 index 000000000..d1e00f679 --- /dev/null +++ b/test/fixtures/graph_editor_e2e_server.jl @@ -0,0 +1,31 @@ +using PlantSimEngine +using PlantSimEngine.Examples +using HTTP + +abstract type AbstractReebE2EModel <: PlantSimEngine.AbstractModel end +PlantSimEngine.process_(::Type{AbstractReebE2EModel}) = :reeb_e2e + +struct ReebE2E{T} <: AbstractReebE2EModel + k::T +end + +ReebE2E() = ReebE2E(0.5) +PlantSimEngine.inputs_(::ReebE2E) = (aPPFD=-Inf,) +PlantSimEngine.outputs_(::ReebE2E) = (LAI=-Inf,) + +abstract type AbstractE2EConsumerModel <: PlantSimEngine.AbstractModel end +PlantSimEngine.process_(::Type{AbstractE2EConsumerModel}) = :e2e_consumer + +struct E2EConsumer <: AbstractE2EConsumerModel end +PlantSimEngine.inputs_(::E2EConsumer) = (aPPFD=-Inf,) +PlantSimEngine.outputs_(::E2EConsumer) = (consumed=-Inf,) + +session = edit_graph(; port=0, open_browser=false, autosave=false) +atexit(() -> try + close(session) +catch +end) + +println("PSE_GRAPH_EDITOR_URL=$(session.url)") +flush(stdout) +wait(Condition()) diff --git a/test/helper-functions.jl b/test/helper-functions.jl deleted file mode 100644 index 0e1524b66..000000000 --- a/test/helper-functions.jl +++ /dev/null @@ -1,415 +0,0 @@ -# doesn't check for mtg equality -function compare_outputs_graphsim(graphsim, graphsim2) - outputs_df_dict = convert_outputs(graphsim.outputs, DataFrame) - outputs2_df_dict = convert_outputs(graphsim2.outputs, DataFrame) - - if length(outputs_df_dict) != length(outputs2_df_dict) - return false - end - - for (organ, vals) in outputs2_df_dict - outputs_df_sorted = outputs_df_dict[organ][:, sortperm(names(outputs_df_dict[organ]))] - outputs2_df_sorted = outputs2_df_dict[organ][:, sortperm(names(outputs2_df_dict[organ]))] - - if outputs_df_sorted != outputs2_df_sorted - return false - end - end - - return true -end - -function compare_outputs_modellists(filtered_outputs_1, filtered_outputs_2) - models_df_1 = DataFrame(filtered_outputs_1) - models_df_sorted_1 = models_df_1[:, sortperm(names(models_df_1))] - models_df_2 = DataFrame(filtered_outputs_2) - models_df_sorted_2 = models_df_2[:, sortperm(names(models_df_2))] - return models_df_sorted_2 == models_df_sorted_1 -end - -function compare_outputs_modellist_mapping(filtered_outputs_modellist, graphsim) - modellist_df = DataFrame(filtered_outputs_modellist) - modellist_sorted = modellist_df[:, sortperm(names(modellist_df))] - - outputs_df = convert_outputs(graphsim.outputs, DataFrame) - @assert haskey(outputs_df, :Default) - common_cols = filter(c -> c in names(outputs_df[:Default]), names(modellist_sorted)) - mapping_sorted = outputs_df[:Default][:, common_cols] - modellist_sorted = modellist_sorted[:, common_cols] - - # Keep deterministic order in case columns are provided in different orders. - mapping_sorted = mapping_sorted[:, sortperm(names(mapping_sorted))] - modellist_sorted = modellist_sorted[:, sortperm(names(modellist_sorted))] - - return modellist_sorted == mapping_sorted -end - -# Helper used to compare a single-scale `ModelMapping` run with its generated -# multiscale equivalent. -function check_multiscale_simulation_is_equivalent_begin(mapping::ModelMapping, meteo) - _, models_at_scale = only(pairs(mapping)) - status_nt = NamedTuple(something(PlantSimEngine.get_status(models_at_scale), Status())) - models = ModelMapping(PlantSimEngine.get_models(models_at_scale)...; status=status_nt) - mtg, mapping, out = PlantSimEngine.modellist_to_mapping(models, status_nt; nsteps=length(meteo), outputs=nothing) - return mtg, mapping, out -end - -function check_multiscale_simulation_is_equivalent_end(modellist_outputs, mtg, mapping, out, meteo) - graph_sim = PlantSimEngine.GraphSimulation(mtg, mapping, nsteps=PlantSimEngine.get_nsteps(meteo), check=true, outputs=out) - - sim = run!(graph_sim, - meteo, - PlantMeteo.Constants(), - nothing; - check=true, - executor=SequentialEx() - ) - - return compare_outputs_modellist_mapping(modellist_outputs, graph_sim) -end - -function check_multiscale_simulation_is_equivalent(mapping::ModelMapping, meteo) - modellist_outputs = run!(mapping, meteo) - mtg, mapping_mt, out = check_multiscale_simulation_is_equivalent_begin(mapping, meteo) - return check_multiscale_simulation_is_equivalent_end(modellist_outputs, mtg, mapping_mt, out, meteo) -end - -# Quick and naive first version. Doesn't check if everything is timestep parallelizable, doesn't check for nthreads etc. -function run_single_and_multi_thread_modellist(mapping::ModelMapping, tracked_outputs, meteo) - out_seq = run!(mapping, meteo; tracked_outputs=tracked_outputs, executor=SequentialEx()) - mapping_mt = copy(mapping) - out_mt = run!(mapping_mt, meteo; tracked_outputs=tracked_outputs, executor=ThreadedEx()) - return out_seq, out_mt -end - -# Could make use of PlantMeteo's online meteo data recovery feature for more numerous examples -# or the random meteo generation used for the PBP benchmark - -#=using PlantMeteo, Dates, DataFrames -# Define the period of the simulation: -period = [Dates.Date("2021-01-01"), Dates.Date("2021-12-31")] -# Get the weather data for CIRAD's site in Montpellier, France: -meteo = get_weather(43.649777, 3.869889, period, sink = DataFrame)=# - -function get_simple_meteo_bank() - - df = CSV.read(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), DataFrame, header=18) - - - meteos = - [Atmosphere(T=20.0, Wind=1.0, P=101.3, Rh=0.65, Ri_PAR_f=300.0), #=nothing,=# - Weather([Atmosphere(T=20.0, Wind=1.0, P=101.3, Rh=0.65)]), - Weather( - [ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f=300.0), - Atmosphere(T=25.0, Wind=0.5, Rh=0.8, Ri_PAR_f=500.0) - ]), Weather([Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f=200.0), - Atmosphere(T=18.0, Wind=1.0, Rh=0.65, Ri_PAR_f=100.0), - Atmosphere(T=19.0, Wind=1.0, Rh=0.65, Ri_PAR_f=200.0), - Atmosphere(T=30.0, Wind=0.5, Rh=0.6, Ri_PAR_f=100.0), - Atmosphere(T=20.0, Wind=1.0, Rh=0.6, Ri_PAR_f=200.0), - Atmosphere(T=25.0, Wind=1.0, Rh=0.6, Ri_PAR_f=200.0), - Atmosphere(T=10.0, Wind=0.5, Rh=0.6, Ri_PAR_f=200.0)]), - df, - df[1, :], - DataFrame(df[1, :]), - ] - return meteos -end - -function get_modellist_bank() - meteo_day = CSV.read(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), DataFrame, header=18) - - rue = 0.3 - - vals = (var1=15.0, var2=0.3)#, TT_cu=cumsum(meteo_day.TT)) - vals2 = (TT_cu=cumsum(meteo_day.TT),) - vals3 = (var1=15.0, var2=0.3) - vals4 = (var9=1.0, var0=1.0) - vals5 = (var0=1.0,) - vals6 = (var0=1.0,) - - status_tuples = [vals, vals2, vals3, vals4, vals5, vals6] - - models = [ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - status=vals - ), - ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(rue), - status=vals2, - ), ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=vals3 - ), ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - process4=Process4Model(), - process5=Process5Model(), - process6=Process6Model(), - # process7=Process7Model(), - status=vals4 - ), ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - process4=Process4Model(), - process5=Process5Model(), - process6=Process6Model(), - process7=Process7Model(), - status=vals5 - ), ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - process4=Process4Model(), - process5=Process5Model(), - status=vals6 - ),] - - outputs_tuples_vectors = - [ - # this one has one tuple with a duplicate, and one with a nonexistent variable - [NamedTuple(), (:var1,), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var5), #=(:var1, :var1),=# - (:var1, :var2, :var3, :var4, :var5)], #=(:var2, :var7, :var3, :var1),=# - [NamedTuple(), (:TT_cu,), (:TT_cu, :LAI), (:biomass, :LAI), (:TT_cu, :LAI, :aPPFD, :biomass, :biomass_increment),], [NamedTuple(), (:var1,), (:var1, :var4), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var6, :var5), - (:var1, :var2, :var3, :var4, :var5, :var6)], #=(:var2, :var7, :var3, :var1),=# - [NamedTuple(), (:var1,), (:var1, :var4), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var6, :var5), - (:var2, :var7, :var3, :var1), (:var1, :var2, :var3, :var4, :var5, :var6)], [NamedTuple(), (:var1,), (:var1, :var4), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var6, :var5), - (:var2, :var7, :var3, :var1), (:var1, :var2, :var3, :var4, :var5, :var6), (:var1, :var2, :var3, :var4, :var5, :var6, :var7, :var8, :var9)], [NamedTuple(), (:var1,), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var6, :var5), #=(:var1, :var1),=# - (:var2, :var7, :var3, :var1), (:var1, :var2, :var3, :var4, :var5, :var6),], #=(:var1, :var2, :var3, :var4, :var5, :var6, :var7, :var8, :var9, :var0)=# - ] - - return models, status_tuples, outputs_tuples_vectors -end - -function get_modelmapping_bank() - meteo_day = CSV.read(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), DataFrame, header=18) - - rue = 0.3 - - vals = (var1=15.0, var2=0.3) - vals2 = (TT_cu=cumsum(meteo_day.TT),) - vals3 = (var1=15.0, var2=0.3) - vals4 = (var9=1.0, var0=1.0) - vals5 = (var0=1.0,) - vals6 = (var0=1.0,) - - status_tuples = [vals, vals2, vals3, vals4, vals5, vals6] - - mappings = [ - ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - status=vals - ), - ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(rue); - status=vals2 - ), - ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=vals3 - ), - ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - process4=Process4Model(), - process5=Process5Model(), - process6=Process6Model(), - status=vals4 - ), - ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - process4=Process4Model(), - process5=Process5Model(), - process6=Process6Model(), - process7=Process7Model(), - status=vals5 - ), - ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - process4=Process4Model(), - process5=Process5Model(), - status=vals6 - ), - ] - - outputs_tuples_vectors = - [ - [NamedTuple(), (:var1,), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var5), - (:var1, :var2, :var3, :var4, :var5)], [NamedTuple(), (:TT_cu,), (:TT_cu, :LAI), (:biomass, :LAI), (:TT_cu, :LAI, :aPPFD, :biomass, :biomass_increment),], [NamedTuple(), (:var1,), (:var1, :var4), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var6, :var5), - (:var1, :var2, :var3, :var4, :var5, :var6)], [NamedTuple(), (:var1,), (:var1, :var4), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var6, :var5), - (:var2, :var7, :var3, :var1), (:var1, :var2, :var3, :var4, :var5, :var6)], [NamedTuple(), (:var1,), (:var1, :var4), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var6, :var5), - (:var2, :var7, :var3, :var1), (:var1, :var2, :var3, :var4, :var5, :var6), (:var1, :var2, :var3, :var4, :var5, :var6, :var7, :var8, :var9)], [NamedTuple(), (:var1,), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var6, :var5), - (:var2, :var7, :var3, :var1), (:var1, :var2, :var3, :var4, :var5, :var6)],] - - return mappings, status_tuples, outputs_tuples_vectors -end - -# Could add some mtg variation too -function get_simple_mapping_bank() - mappings = [ - ModelMapping( - :Scene => ToyDegreeDaysCumulModel(), - :Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[:TT_cu => (:Scene => :TT_cu),],), - Beer(0.6), - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode],],), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],],),), - :Internode => ( - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => (:Scene => :TT),],), - MultiScaleModel( - model=ToyInternodeEmergence(TT_emergence=20.0), - mapped_variables=[:TT_cu => (:Scene => :TT_cu)],), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(carbon_biomass=1.0)), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content), :aPPFD => (:Plant => :aPPFD)],), - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => (:Scene => :TT),],), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - Status(carbon_biomass=1.0)), - :Soil => (ToySoilWaterModel(),),), - ########## - ModelMapping( - :Default => ( - Process1Model(1.0), - Status(var1=15.0, var2=0.3,),),), - ########## - ModelMapping( - :Plant => ( - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - # inputs - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - # outputs - :carbon_allocation => [:Leaf, :Internode],],), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],],),), - :Internode => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(TT=10.0, carbon_biomass=1.0)), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], - # Notice we provide :Soil, not [:Soil], so a single value is expected here - ), - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - Status(aPPFD=1300.0, TT=10.0, carbon_biomass=1.0), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025),), - :Soil => (ToySoilWaterModel(),),), - ################## - ] - - out_vars_vectors = [ - [nothing, - NamedTuple(), - Dict(), - #Dict(:Leaf => NamedTuple()), # incorrect - Dict(:Leaf => (:carbon_allocation,),), - Dict(:Leaf => (:carbon_demand,),), - Dict( - :Leaf => (:carbon_assimilation, :carbon_demand, :soil_water_content, :carbon_allocation), - :Internode => (:carbon_allocation, :TT_cu_emergence), - :Plant => (:carbon_allocation,), - :Soil => (:soil_water_content,),),], - ############# - [nothing, - NamedTuple(), - Dict(:Default => (:var1,)) - ], - ############# - [ - nothing, - NamedTuple(), - Dict( - :Leaf => (:carbon_assimilation, :carbon_demand), - :Soil => (:soil_water_content,), - ),], - ] - mtgs = [ - import_mtg_example(), - MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Default, 0, 0),), - import_mtg_example() - ] - return mtgs, mappings, out_vars_vectors -end - - -function test_filtered_output_begin(m::ModelMapping, status_tuple, requested_outputs, meteo) - - nsteps = PlantSimEngine.get_nsteps(meteo) - preallocated_outputs = PlantSimEngine.pre_allocate_outputs(m, requested_outputs, nsteps) - @test length(preallocated_outputs) == nsteps - if length(requested_outputs) > 0 - @test length(preallocated_outputs[1]) == length(requested_outputs) - else - # don't compare with the status because unnecessary variables in the status are discarded in the filtered outputs - out_vars_all = merge(init_variables(m; verbose=false)...) - println(out_vars_all) - @test length(preallocated_outputs[1]) == length(out_vars_all) - end - - filtered_outputs_modellist = run!(m, meteo; tracked_outputs=requested_outputs, executor=SequentialEx()) - - # compare filtered output of a modellist with the filtered output of the equivalent simulation in multiscale mode - mtg, mapping, outputs_mapping = PlantSimEngine.modellist_to_mapping(m, status_tuple; nsteps=nsteps, outputs=requested_outputs) - - return mtg, mapping, outputs_mapping, nsteps, filtered_outputs_modellist -end - -function test_filtered_output(mtg, mapping, nsteps, outputs_mapping, meteo, filtered_outputs_modellist) - graphsim = PlantSimEngine.GraphSimulation(mtg, mapping, nsteps=nsteps, check=true, outputs=outputs_mapping) - - sim2 = run!(graphsim, - meteo, - PlantMeteo.Constants(), - nothing; - check=true, - executor=SequentialEx() - ) - return compare_outputs_modellist_mapping(filtered_outputs_modellist, graphsim) -end - -function test_filtered_output(m::ModelMapping, status_tuple, requested_outputs, meteo) - mtg, mapping, outputs_mapping, nsteps, filtered_outputs_modellist = - test_filtered_output_begin(m, status_tuple, requested_outputs, meteo) - - @test to_initialize(mapping) == Dict() - return test_filtered_output(mtg, mapping, nsteps, outputs_mapping, meteo, filtered_outputs_modellist) -end diff --git a/test/runtests.jl b/test/runtests.jl index 99cc88cdb..cc61d689a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -5,83 +5,125 @@ using Test, Aqua using Tables, DataFrames, CSV using MultiScaleTreeGraph using PlantMeteo, Statistics +using HTTP +using JSON using Documenter # for doctests -include("helper-functions.jl") - # There are 3 kinds of tests : # PSE functionality/feature tests # Integration tests (launched in Github Actions, they run PBP and XPalm tests) # Benchmarks both internal and downstream, located in the downstream folder, and run in another Github Action -@testset "Testing PlantSimEngine" begin +if length(ARGS) == 1 && endswith(only(ARGS), ".jl") + focused_file = basename(only(ARGS)) + focused_path = joinpath(@__DIR__, focused_file) + isfile(focused_path) || error("Unknown focused test file `$(focused_file)`.") + @testset "Focused: $(focused_file)" begin + include(focused_path) + end +else + @testset "Testing PlantSimEngine" begin Aqua.test_all(PlantSimEngine, ambiguities=false) Aqua.test_ambiguities([PlantSimEngine]) - @testset "ModelMapping: single scale" begin - include("test-ModelMapping.jl") + @testset "Unified model/object API" begin + include("test-unified-model-object-api.jl") end - @testset "ModelMapping: multi scale" begin - include("test-mapping.jl") + @testset "Composite Model/Object API stabilization" begin + include("test-model-api-stabilization.jl") end - @testset "Multi-rate scaffolding" begin - include("test-multirate-scaffolding.jl") + @testset "Composite model hard calls" begin + include("test-model-hard-calls.jl") end - @testset "Multi-rate runtime" begin - include("test-multirate-runtime.jl") + @testset "Composite model numerical parity" begin + include("test-model-numerical-parity.jl") end - @testset "Multi-rate output export" begin - include("test-multirate-output-export.jl") + @testset "Composite model status initialization" begin + include("test-model-status-initialization.jl") end - @testset "MultiScaleModel" begin - include("test-MultiScaleModel.jl") + @testset "Composite model output boundaries" begin + include("test-model-output-boundaries.jl") end - @testset "Status" begin - include("test-Status.jl") + @testset "Composite model time validation" begin + include("test-model-time-validation.jl") end - @testset "TimeStepTable" begin - include("test-TimeStepTable.jl") + @testset "Composite model runtime matrix" begin + include("test-model-runtime-matrix.jl") end - @testset "Dimensions" begin - include("test-dimensions.jl") + @testset "Composite model meteorological sampling" begin + include("test-model-meteo-sampling.jl") end - @testset "Simulations" begin - include("test-simulation.jl") + @testset "Composite model temporal reducers" begin + include("test-model-temporal-reducers.jl") end - @testset "Statistics" begin - include("test-statistics.jl") + @testset "Composite model binding inference" begin + include("test-model-binding-inference.jl") end - @testset "Fitting" begin - include("test-fitting.jl") + @testset "Composite model multirate integration" begin + include("test-model-multirate-integration.jl") end - @testset "Toy models" begin - include("test-toy_models.jl") + @testset "Composite model configuration errors" begin + include("test-model-configuration-errors.jl") + end + + @testset "Model graph viewer" begin + include("test-model-graph-view.jl") + end + + @testset "Model graph editor extension" begin + include("test-model-graph-editor-extension.jl") end - @testset "MTG with multiscale mapping" begin - include("test-mtg-multiscale.jl") - include("test-mtg-dynamic.jl") - include("test-mtg-multiscale-cyclic-dep.jl") + @testset "Model contract" begin + include("test-model-contract.jl") end - @testset "Multiscale corner-cases" begin - include("test-corner-cases.jl") + @testset "ModelSpec Updates" begin + include("test-updates.jl") end - @testset "Multithreading" begin - include("test-performance.jl") + @testset "Meteo traits" begin + include("test-meteo-traits.jl") + end + + @testset "Environment backends" begin + include("test-environment-backends.jl") + end + + @testset "MAESPA-style model example" begin + include("test-maespa-model-example.jl") + end + + @testset "Status" begin + include("test-Status.jl") + end + + @testset "TimeStepTable" begin + include("test-TimeStepTable.jl") + end + + @testset "Statistics" begin + include("test-statistics.jl") + end + + @testset "Fitting" begin + include("test-fitting.jl") + end + + @testset "Toy models" begin + include("test-toy_models.jl") end if VERSION >= v"1.10" @@ -93,4 +135,5 @@ include("helper-functions.jl") doctest(PlantSimEngine; manual=false) end end + end end diff --git a/test/test-ModelMapping.jl b/test/test-ModelMapping.jl deleted file mode 100644 index 7d6e37f96..000000000 --- a/test/test-ModelMapping.jl +++ /dev/null @@ -1,323 +0,0 @@ -# Tests: -# Defining a list of models without status: - -@testset "ModelMapping with no status" begin - leaf = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model() - ) - - inits = merge(init_variables(leaf.models)...) - st = Status{keys(inits)}(values(inits)) - @test all(getproperty(leaf.status, i)[1] == getproperty(st, i) for i in keys(st)) - @test !is_initialized(leaf) - @test to_initialize(leaf) == (process1=(:var1, :var2), process2=(:var1,)) - @test length(status(leaf)) == 5 - - # Requiring 3 steps for initialization: - leaf = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - ) - - @test length(status(leaf)) == 5 - @test status(leaf, :var1) == -Inf -end; - - -@testset "process" begin - @test PlantSimEngine.process(Process1Model(1.0)) == :process1 - @test PlantSimEngine.process(:process1 => Process1Model(1.0)) == :process1 - - models = - ( - Process1Model(1.0), - Process2Model() - ) - - @test [(process(i), i) for i in models] == Tuple{Symbol,AbstractModel}[(:process1, Process1Model(1.0)), (:process2, Process2Model())] - - models_named = ( - process1=Process1Model(1.0), - process2=Process2Model() - ) - - @test [(process(i), i) for i in models_named] == [(process(i), i) for i in models] -end - -@testset "ModelMapping with no process names" begin - with_names = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model() - ) - - without_names = ModelMapping( - Process1Model(1.0), - Process2Model() - ) - - @test with_names.models == without_names.models - @test with_names.status.var1 == without_names.status.var1 - @test with_names.status.var2 == without_names.status.var2 -end; - -@testset "ModelMapping with a partially initialized status" begin - leaf = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - status=(var1=15.0,) - ) - - inits = merge(init_variables(leaf.models)...) - st = Status{keys(inits)}(values(inits)) - st.var1 = 15.0 - @test all(getproperty(leaf.status, i)[1] == getproperty(st, i) for i in keys(st)) - @test !is_initialized(leaf) - @test to_initialize(leaf) == (process1=(:var2,),) - - # Requiring 3 steps for initialization: - leaf = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - status=(var1=15.0,), - ) - - @test length(status(leaf)) == 5 - @test status(leaf, :var1) == 15.0 -end; - -@testset "ModelMapping with fully initialized status" begin - vals = (var1=15.0, var2=0.3) - leaf = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - status=vals - ) - - inits = merge(init_variables(leaf.models)...) - st = Status{keys(inits)}(values(inits)) - - for i in keys(vals) - setproperty!(st, i, getproperty(vals, i)) - end - @test all(getproperty(leaf.status, i)[1] == getproperty(st, i) for i in keys(st)) - - @test is_initialized(leaf) - @test to_initialize(leaf) == NamedTuple() -end; - - -@testset "ModelMapping with independant models (and missing one in the middle)" begin - vals = (var1=15.0, var2=0.3) - leaf = ModelMapping( - process1=Process1Model(1.0), - process3=Process3Model(), - status=vals - ) - - @test to_initialize(leaf) == (process3=(:var5,),) - - # NB: decompose this test because the order of the variables change with the Julia version - inits = init_variables(leaf) - sorted_vars = sort([keys(inits.process3)...]) - - @test [getfield(inits.process3, i) for i in sorted_vars] == fill(-Inf, 3) -end; - -@testset "Copy a ModelMapping" begin - vars = (var1=15.0, var2=0.3) - # Create a model list: - models = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=vars - ) - - # Copy the model list: - ml2 = copy(models) - - @test DataFrame(TimeStepTable([status(ml2)])) == DataFrame(TimeStepTable([status(models)])) - - # Copy the model list with new status: - st = Status(var1=20.0, var2=0.5) - ml3 = copy(models, st) - - @test status(ml3) == st - @test ml3.models == models.models - - - cp_models = copy([models, ml3]) - @test cp_models == [models, ml3] - - cp_models = copy(Dict("models" => models, "ml3" => ml3)) - @test cp_models == Dict("models" => models, "ml3" => ml3) -end; - -@testset "Convert ModelMapping status variables into new types" begin - ref_vars = init_variables( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - ) - type_promotion = Dict(Real => Float32) - - process3_Float32 = PlantSimEngine.convert_vars(ref_vars.process3, type_promotion) - - @test all([isa(getfield(process3_Float32, i), Float32) for i in keys(process3_Float32)]) - - process3_same = PlantSimEngine.convert_vars(ref_vars.process3, nothing) - @test process3_same == ref_vars.process3 - - mapping = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(); - type_promotion=type_promotion, - ) - - @test mapping.type_promotion == type_promotion - @test all(isa(status(mapping)[var], Float32) for var in keys(status(mapping))) -end - - -@testset "ModelMapping dependencies" begin - - models = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - process4=Process4Model(), - process5=Process5Model(), - process6=Process6Model(), - # process7=Process7Model(), - # status=(var1=15.0, var2=0.3) - ) - - deps = dep(models).roots - - @test collect(keys(deps)) == [:process4] - - @test deps[:process4].value == Process4Model() - @test isa(deps[:process4], PlantSimEngine.SoftDependencyNode) - - process3 = deps[:process4].children[1] - @test process3.value == Process3Model() - @test isa(process3, PlantSimEngine.SoftDependencyNode) - - @test process3.hard_dependency[1].value == Process2Model() - @test isa(process3.hard_dependency[1], PlantSimEngine.HardDependencyNode) - - @test process3.hard_dependency[1].children[1].value == Process1Model(1.0) - @test isa(process3.hard_dependency[1].children[1], PlantSimEngine.HardDependencyNode) - - @test process3.children[1].value == Process5Model() - @test isa(process3.children[1], PlantSimEngine.SoftDependencyNode) -end - - - - -# very naive function, doesn't generate full partition sets -# insert_errors : could duplicate a value, add a nonexistent one, make one the wrong type ? -#=function generate_output_tuple(vars_tuple, insert_errors, count) - - outputs_tuples_vector = [NamedTuple()] - - # number not exact, but trying every permutation sounds like a waste of time - for i in 1:max(count, length(vars_tuple)) - new_tuple = () - # TODO - new_tuple = (new_tuple..., new_var) - end - return outputs_tuples_vector -end=# - - - -@testset "ModelMapping outputs preallocation" begin - meteo_day = CSV.read(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), DataFrame, header=18) - vals = (var1=15.0, var2=0.3, TT_cu=cumsum(meteo_day.TT)) - leaf = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - status=vals - ) - outs = (:var3,) - - @test test_filtered_output(leaf, vals, outs, meteo_day) - - meteos = - [Atmosphere(T=20.0, Wind=1.0, P=101.3, Rh=0.65, Ri_PAR_f=300.0), - CSV.read(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), DataFrame, header=18), - ] - modellists, status_tuples, outs_vectors = get_modellist_bank() - - # remove some of the currently unhandled cases - outs_vectors = - [ - # this one has one tuple with a duplicate, and one with a nonexistent variable - [(:var1,), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var5), #=(:var1, :var1),=# - (:var1, :var2, :var3, :var4, :var5)], #=(:var2, :var7, :var3, :var1),=# - [(:TT_cu,), (:TT_cu, :LAI), (:biomass, :LAI), (:TT_cu, :LAI, :aPPFD, :biomass, :biomass_increment),], #=NamedTuple(),=# - [(:var1,), (:var1, :var4), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var6, :var5), #=NamedTuple(),=# - (:var1, :var2, :var3, :var4, :var5, :var6)], #=(:var2, :var7, :var3, :var1),=# - [(:var1,), (:var1, :var4), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var6, :var5), #=NamedTuple(),=# - (:var2, :var7, :var3, :var1), (:var1, :var2, :var3, :var4, :var5, :var6)], - [(:var1,), (:var1, :var4), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var6, :var5), #=NamedTuple(),=# - (:var2, :var7, :var3, :var1), (:var1, :var2, :var3, :var4, :var5, :var6), (:var1, :var2, :var3, :var4, :var5, :var6, :var7, :var8, :var9)], - [(:var1,), (:var1, :var2), (:var1, :var3), (:var1, :var4, :var6, :var5), #=(:var1, :var1),=# - (:var2, :var7, :var3, :var1), (:var1, :var2, :var3, :var4, :var5, :var6), (:var1, :var2, :var3, :var4, :var5, :var6, :var7, :var0)], #=:var8, :var9,=# - ] - - - - for i in 1:length(modellists) - - modellist = modellists[i] - status_tuple = status_tuples[i] - outs_vector = outs_vectors[i] - all_vars = init_variables(modellist) - - #insert_errors = true - #outs_vector = generate_output_tuple(all_vars, insert_errors) - - for j in 1:length(meteos) - meteo = meteos[j] - for k in 1:length(outs_vector) - out_tuple = outs_vector[k] - #print(i, " ", j, " ", k) - meteo_adjusted = PlantSimEngine.adjust_weather_timesteps_to_given_length( - PlantSimEngine.get_status_vector_max_length(modellist.status), meteo) - @test test_filtered_output(modellist, status_tuple, out_tuple, meteo_adjusted) - end - end - end - - #mtg, mapping, outputs_mapping, nsteps, filtered_outputs_modellist = test_filtered_output_begin(modellists[1], status_tuples[1], outs_vectors[1][1], meteos[1]) - #@test test_filtered_output(mtg, mapping, nsteps, outputs_mapping, meteo_day, filtered_outputs_modellist) -end - - -PlantSimEngine.@process "modellist_cycle" verbose = false - -struct Reeb{T} <: AbstractModellist_CycleModel - k::T -end - -function PlantSimEngine.run!(::Reeb, models, status, meteo, constants, extra=nothing) - status.LAI = - status.aPPFD + 0.4 * k -end - -function PlantSimEngine.inputs_(::Reeb) - (aPPFD=-Inf,) -end - -function PlantSimEngine.outputs_(::Reeb) - (LAI=-Inf,) -end - -@testset "ModelMapping simple cyclic dependency detection" begin - @test_throws "Cyclic" m = ModelMapping(Beer(0.5), Reeb(0.5)) -end diff --git a/test/test-MultiScaleModel.jl b/test/test-MultiScaleModel.jl deleted file mode 100644 index 5096fa886..000000000 --- a/test/test-MultiScaleModel.jl +++ /dev/null @@ -1,83 +0,0 @@ -@testset "MultiScaleModel: mapping formatting" begin - std_mapping = :plant_surfaces => (:Plant => :plant_surfaces) - @test PlantSimEngine._get_var(:plant_surfaces => (:Plant => :plant_surfaces)) == std_mapping # Case 1 - @test PlantSimEngine._get_var(:plant_surfaces => [:Plant]) == (:plant_surfaces => [:Plant => :plant_surfaces]) # Case 2 - @test PlantSimEngine._get_var(:plant_surfaces => [:Plant, :Leaf]) == (:plant_surfaces => [:Plant => :plant_surfaces, :Leaf => :plant_surfaces]) # Case 3 - @test PlantSimEngine._get_var(:plant_surfaces => (:Plant => :plant_surfaces)) == std_mapping # Similar to case 1 - @test PlantSimEngine._get_var(:plant_surfaces => (:Plant => :surface)) == (:plant_surfaces => (:Plant => :surface)) # Case 4 - @test PlantSimEngine._get_var(:plant_surfaces => :Plant) == std_mapping # Case 1 shorthand with symbol scale - @test PlantSimEngine._get_var(:plant_surfaces => [:Plant => :surface, :Leaf => :surface]) == (:plant_surfaces => [:Plant => :surface, :Leaf => :surface]) # Case 5 - @test PlantSimEngine._get_var(:plant_surfaces => [:Plant => :surface_1, :Leaf => :surface_2]) == (:plant_surfaces => [:Plant => :surface_1, :Leaf => :surface_2]) # Case 5 - @test PlantSimEngine._get_var(PreviousTimeStep(:plant_surfaces) => (:Plant => :plant_surfaces)) == (PreviousTimeStep(:plant_surfaces, :unknown) => :Plant => :plant_surfaces) # Case 6 - @test PlantSimEngine._get_var(PreviousTimeStep(:plant_surfaces) => :Plant => :surface) == (PreviousTimeStep(:plant_surfaces, :unknown) => :Plant => :surface) # Case 6 - @test PlantSimEngine._get_var(PreviousTimeStep(:plant_surfaces) => [:Plant => :surface, :Leaf => :surface]) == (PreviousTimeStep(:plant_surfaces, :unknown) => [:Plant => :surface, :Leaf => :surface]) # Case 6 - @test PlantSimEngine._get_var(PreviousTimeStep(:plant_surfaces)) == (PreviousTimeStep(:plant_surfaces, :unknown) => Symbol("") => :plant_surfaces) # Case 7 - @test PlantSimEngine._get_var(PreviousTimeStep(:plant_surfaces) => (Symbol("") => :surface)) == (PreviousTimeStep(:plant_surfaces, :unknown) => Symbol("") => :surface) - @test PlantSimEngine._get_var(PreviousTimeStep(:plant_surfaces) => (Symbol("") => :surface), :test) == (PreviousTimeStep(:plant_surfaces, :test) => Symbol("") => :surface) -end; - -@testset "MultiScaleModel: case 1" begin - models = MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[:TT_cu => (:Scene => :TT_cu),], - ) - - @test models.model == ToyLAIModel() - @test models.mapped_variables == [:TT_cu => (:Scene => :TT_cu)] -end; - -@testset "MultiScaleModel: case 2" begin - models = MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[:TT_cu => [:Plant],], - ) - - @test models.model == ToyLAIModel() - @test models.mapped_variables == [:TT_cu => [:Plant => :TT_cu]] - - - models = MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[:TT_cu => [:Leaf, :Internode],], - ) - - @test models.model == ToyLAIModel() - @test models.mapped_variables == [:TT_cu => [:Leaf => :TT_cu, :Internode => :TT_cu]] -end; - - -@testset "MultiScaleModel: case 2, several variables with different format" begin - models = MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[:carbon_assimilation => [:Leaf], :carbon_demand => [:Leaf, :Internode], :Rm => (:Plant => :Rm_plant)], - ) - - @test models.model == ToyCAllocationModel() - @test models.mapped_variables == [:carbon_assimilation => [:Leaf => :carbon_assimilation], :carbon_demand => [:Leaf => :carbon_demand, :Internode => :carbon_demand], :Rm => (:Plant => :Rm_plant)] -end; - - -@testset "MultiScaleModel: case with PreviousTimeStep => ..." begin - models = MultiScaleModel( - model=ToyLAIfromLeafAreaModel(1.0), - mapped_variables=[ - PreviousTimeStep(:plant_surfaces) => :Plant => :surface, - ], - ) - - @test models.model == ToyLAIfromLeafAreaModel(1.0) - @test models.mapped_variables == [PreviousTimeStep(:plant_surfaces, :LAI_Dynamic) => (:Plant => :surface)] -end; - -@testset "MultiScaleModel: several types of mapping" begin - models = MultiScaleModel( - model=ToyLightPartitioningModel(), - mapped_variables=[ - :aPPFD_larger_scale => (:Scene => :aPPFD), - :total_surface => (:Scene => :total_surface) - ], - ) - - @test models.model == ToyLightPartitioningModel() - @test models.mapped_variables == [:aPPFD_larger_scale => (:Scene => :aPPFD), :total_surface => (:Scene => :total_surface)] -end diff --git a/test/test-Status.jl b/test/test-Status.jl index 0bdcfac35..244f5cd96 100644 --- a/test/test-Status.jl +++ b/test/test-Status.jl @@ -29,47 +29,3 @@ mnt2[:b] = "hello" @test mnt2.b == "hello" end - -@testset "Testing ModelMapping Status" begin - # Create a ModelMapping - models = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=[15.0, 16.0], var2=0.3) - ) - - @test typeof(status(models)) == Status{ - (:var5, :var4, :var6, :var1, :var3, :var2), - Tuple{ - Base.RefValue{Float64},Base.RefValue{Float64},Base.RefValue{Float64}, - Base.RefValue{Vector{Float64}},Base.RefValue{Float64},Base.RefValue{Float64}} - } - - - @test status(models) == models.status - @test status(models)[1] == status(models, 1) - - @test typeof(status(models, 1)) == Float64 - @test typeof(status(models, 4)) == Vector{Float64} - - @test status(models, :var1)[1] == 15.0 - @test status(models, 6) == 0.3 - @test status(models, :var1) == [15.0, 16.0] - @test status(models, :var2) == 0.3 - - @test status(models, :var4) == -Inf - @test status(models, :var3) == -Inf - @test status(models, :var5) == -Inf - @test status(models, :var6) == -Inf - - # TODO this behaviour is now changed, ramifications hard to gauge - # Testing setindex: - #@test models[:var6] = [5.5, 5.8] - #@test status(models, :var6) == [5.5, 5.8] - - # Testing a vector of ModelMapping: - @test status([models, models]) == [models.status, models.status] - # Testing a Dict of ModelMapping: - @test status(Dict(:m1 => models, :m2 => models)) == Dict(:m1 => models.status, :m2 => models.status) -end \ No newline at end of file diff --git a/test/test-TimeStepTable.jl b/test/test-TimeStepTable.jl index 02c601bff..0122c04bf 100644 --- a/test/test-TimeStepTable.jl +++ b/test/test-TimeStepTable.jl @@ -1,6 +1,6 @@ -@testset "Testing TimeStepTable{Status}" begin +@testset "Testing Advanced.TimeStepTable{Status}" begin vars = Status(Ra_SW_f=13.747, sky_fraction=1.0, d=0.03, aPPFD=1500) - ts = TimeStepTable([vars, vars]) + ts = Advanced.TimeStepTable([vars, vars]) @test Tables.istable(typeof(ts)) diff --git a/test/test-corner-cases.jl b/test/test-corner-cases.jl deleted file mode 100644 index bf92bbe87..000000000 --- a/test/test-corner-cases.jl +++ /dev/null @@ -1,619 +0,0 @@ - -# Specific configurations that trigger specific codepaths, or relate to, say, past bugs exposed in XPalm over time -# Usually have some subtle coupling quirk that requires careful handling in the dependency graph -# The outputs and meteo values are irrelevant, those are here as guards that are likely to break if a larger rework -# fails to take those corner-cases into account, and more quickly checked than XPalm - -############################################################################################################### -## Multi-Scale setup with a hard dependency calling another hard dependency -############################################################################################################### - -# relates to #77 and #99 - -PlantSimEngine.@process "Msg3Lvl_amont" verbose = false -PlantSimEngine.@process "Msg3Lvl_amont2" verbose = false -PlantSimEngine.@process "Msg3Lvl_echelle1" verbose = false -PlantSimEngine.@process "Msg3Lvl_echelle2" verbose = false -PlantSimEngine.@process "Msg3Lvl_echelle3" verbose = false -PlantSimEngine.@process "Msg3Lvl_aval" verbose = false -PlantSimEngine.@process "Msg3Lvl_aval2" verbose = false - -# Roots : amont and amont2 -# amont2 points to aval -# aval has a hard dependency, aval2 -# ech3 is a hard dependency of ech2, itself a hard dependency of ech1 -# all 3 use variables from amont, making amont a soft dependency of ech1 - -# aval makes use of variables from amont2, aval2, ech1 and ech3 - -################# - -struct Msg3LvlScaleAmontModel <: AbstractMsg3Lvl_AmontModel -end - -function PlantSimEngine.inputs_(::Msg3LvlScaleAmontModel) - (a=-Inf,) -end - -function PlantSimEngine.outputs_(::Msg3LvlScaleAmontModel) - (b=-Inf, c=-Inf) -end - -function PlantSimEngine.run!(::Msg3LvlScaleAmontModel, models, status, meteo, constants=nothing, extra_args=nothing) - status.b = status.a - status.c = 1.0 -end - -################# - -struct Msg3LvlScaleAmont2Model <: AbstractMsg3Lvl_Amont2Model -end - -function PlantSimEngine.inputs_(::Msg3LvlScaleAmont2Model) - (a2=-Inf,) -end - -function PlantSimEngine.outputs_(::Msg3LvlScaleAmont2Model) - (b2=-Inf,) -end - -function PlantSimEngine.run!(::Msg3LvlScaleAmont2Model, models, status, meteo, constants=nothing, extra_args=nothing) - status.b2 = status.a2 + 1.0 -end - -################# - -struct Msg3LvlScaleEchelle3Model <: AbstractMsg3Lvl_Echelle3Model -end - -function PlantSimEngine.inputs_(::Msg3LvlScaleEchelle3Model) - #(b = -Inf, - (c=-Inf,) -end - -function PlantSimEngine.outputs_(::Msg3LvlScaleEchelle3Model) - (e3=-Inf, f3=-Inf) -end - -function PlantSimEngine.run!(::Msg3LvlScaleEchelle3Model, models, status, meteo, constants=nothing, extra_args=nothing) - status.e3 = 1.0#status.c> - status.f3 = 1.0 -end - -################# - -struct Msg3LvlScaleEchelle2Model <: AbstractMsg3Lvl_Echelle2Model -end - - -function PlantSimEngine.inputs_(::Msg3LvlScaleEchelle2Model) - (c=-Inf, e3=-Inf, f3=-Inf) -end - -function PlantSimEngine.outputs_(::Msg3LvlScaleEchelle2Model) - (e2=-Inf, f2=-Inf) -end - -PlantSimEngine.dep(::Msg3LvlScaleEchelle2Model) = (Msg3Lvl_echelle3=AbstractMsg3Lvl_Echelle3Model => (:E3,),) -function PlantSimEngine.run!(::Msg3LvlScaleEchelle2Model, models, status, meteo, constants=nothing, extra_args=nothing) - status_E3 = extra_args.statuses[:E3][1] - run!(extra_args.models[:E3].Msg3Lvl_echelle3, models, status_E3, meteo, constants) - status.e2 = status.e3 - status.e3 = status.e3 * 2.0 - status.f2 = status.e3 * 2.0 + status.f3 + status.c -end - -################# - -struct Msg3LvlScaleEchelle1Model <: AbstractMsg3Lvl_Echelle1Model -end - -function PlantSimEngine.inputs_(::Msg3LvlScaleEchelle1Model) - (b=-Inf, e2=-Inf, f2=-Inf) -end - -function PlantSimEngine.outputs_(::Msg3LvlScaleEchelle1Model) - (e1=-Inf, f1=-Inf)#, e3 = -Inf) -end - -PlantSimEngine.dep(::Msg3LvlScaleEchelle1Model) = (Msg3Lvl_echelle2=AbstractMsg3Lvl_Echelle2Model => (:E2,),) -function PlantSimEngine.run!(::Msg3LvlScaleEchelle1Model, models, status, meteo, constants=nothing, extra_args=nothing) - - status_E2 = extra_args.statuses[:E2][1] - run!(extra_args.models[:E2].Msg3Lvl_echelle2, models, status_E2, meteo, constants, extra_args) - status.e1 = status.e2 - status.e2 = status.e2 * 2.0 - status.f1 = status.e2 * 2.0 + status.f2 + status.b - #status.e3 = status.e3 * 7.0 -end - -################# - -struct Msg3LvlScaleAval2Model <: AbstractMsg3Lvl_Aval2Model -end - -function PlantSimEngine.inputs_(::Msg3LvlScaleAval2Model) - (i2=-Inf,) -end - -function PlantSimEngine.outputs_(::Msg3LvlScaleAval2Model) - (g2=-Inf,) -end - -function PlantSimEngine.run!(::Msg3LvlScaleAval2Model, models, status, meteo, constants=nothing, extra_args=nothing) - status.g2 = status.i2 -end - -################# - -struct Msg3LvlScaleAvalModel <: AbstractMsg3Lvl_AvalModel -end - -function PlantSimEngine.inputs_(::Msg3LvlScaleAvalModel) - (e1=-Inf, f1=-Inf, b2=-Inf, g2=-Inf, e3=-Inf) -end - -function PlantSimEngine.outputs_(::Msg3LvlScaleAvalModel) - (g=-Inf,) -end - -PlantSimEngine.dep(::Msg3LvlScaleAvalModel) = (Msg3Lvl_aval2=AbstractMsg3Lvl_Aval2Model => (:E2,),) - -function PlantSimEngine.run!(::Msg3LvlScaleAvalModel, models, status, meteo, constants=nothing, extra_args=nothing) - - status_E2 = extra_args.statuses[:E2][1] - run!(extra_args.models[:E2].Msg3Lvl_aval2, models, status_E2, meteo, constants, extra_args) - status.g = status.f1 + status.b2 + status_E2.g2 - status.e3 = status.e3 + 1.0 -end - -##################################################################################### -# actual testset - -@testset "Multiscale nested hard dependencies" begin - - mapping3Lvl = ModelMapping(:E1 => ( - Msg3LvlScaleAmontModel(), - MultiScaleModel( - model=Msg3LvlScaleAvalModel(), - mapped_variables=[:e3 => :E3 => :e3, :b2 => :E2 => :b2, :g2 => :E2 => :g2], - ), - MultiScaleModel( - model=Msg3LvlScaleEchelle1Model(), - mapped_variables=[:e2 => :E2 => :e2, :f2 => :E2 => :f2,], - ), Status(a=1.0,)# y = 1.0, z = 1.0) - ), - :E2 => ( - Msg3LvlScaleAmont2Model(), - Msg3LvlScaleAval2Model(), - MultiScaleModel( - model=Msg3LvlScaleEchelle2Model(), - mapped_variables=[:c => :E1 => :c, :e3 => :E3 => :e3, :f3 => :E3 => :f3,], - ), - Status(a2=1.0, i2=1.0,) - ), - :E3 => ( - MultiScaleModel( - model=Msg3LvlScaleEchelle3Model(), - mapped_variables=[:c => :E1 => :c,], - ), - ), - ) - - outs3Lvl = Dict( - :E1 => (:g, :e1, :f1), - :E2 => (:e2, :f2,), - :E3 => (:e3,) - ) - - meteo3Lvl = Weather([Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f=200.0), - Atmosphere(T=18.0, Wind=1.0, Rh=0.65, Ri_PAR_f=100.0), - Atmosphere(T=19.0, Wind=1.0, Rh=0.65, Ri_PAR_f=200.0), - Atmosphere(T=30.0, Wind=0.5, Rh=0.6, Ri_PAR_f=100.0), - Atmosphere(T=20.0, Wind=1.0, Rh=0.6, Ri_PAR_f=200.0), - Atmosphere(T=25.0, Wind=1.0, Rh=0.6, Ri_PAR_f=200.0), - Atmosphere(T=10.0, Wind=0.5, Rh=0.6, Ri_PAR_f=200.0)]) - - mtg3Lvl = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :E1, 0, 0),) - Node(mtg3Lvl, MultiScaleTreeGraph.NodeMTG("/", :E2, 0, 1)) - Node(mtg3Lvl, MultiScaleTreeGraph.NodeMTG("/", :E3, 0, 2)) - - #sim3Lvl = @test_nowarn PlantSimEngine.run!(mtg3Lvl, mapping3Lvl, meteo3Lvl, tracked_outputs=outs3Lvl, executor=SequentialEx()) - nsteps = PlantSimEngine.get_nsteps(meteo3Lvl) - sim3Lvl = PlantSimEngine.GraphSimulation(mtg3Lvl, mapping3Lvl, nsteps=nsteps, check=false, outputs=outs3Lvl) - out = run!(sim3Lvl, meteo3Lvl) - - @test length(sim3Lvl.dependency_graph.roots) == 2 - - roots = [last(x) for x in collect(sim3Lvl.dependency_graph.roots)] - idx = findfirst(r -> !isempty(r.children) && !isempty(r.children[1].hard_dependency), roots) - @test !isnothing(idx) - model_ech1 = roots[idx].children[1] - @test !isempty(model_ech1.hard_dependency) - @test all(hd.parent == model_ech1 for hd in model_ech1.hard_dependency) - -end - -################################################################################################################################################################################# - -####################################################################################################################### -## Hard dep at another scale, soft dep on the nested model (both at same scale) -####################################################################################################################### - -PlantSimEngine.@process "hard_dep_same_scale_echelle1" verbose = false -PlantSimEngine.@process "hard_dep_same_scale_echelle1bis" verbose = false -PlantSimEngine.@process "hard_dep_same_scale_echelle3" verbose = false -PlantSimEngine.@process "hard_dep_same_scale_aval" verbose = false - -################# - -struct HardDepSameScaleEchelle3Model <: AbstractHard_Dep_Same_Scale_Echelle3Model -end - -function PlantSimEngine.inputs_(::HardDepSameScaleEchelle3Model) - #(b = -Inf, - (d=-Inf,) -end - -function PlantSimEngine.outputs_(::HardDepSameScaleEchelle3Model) - (e3=-Inf, f3=-Inf) -end - -function PlantSimEngine.run!(::HardDepSameScaleEchelle3Model, models, status, meteo, constants=nothing, extra_args=nothing) - status.e3 = 1.0#status.c - status.f3 = 1.0 -end - -################# - -struct HardDepSameScaleEchelle1Model <: AbstractHard_Dep_Same_Scale_Echelle1Model -end - -function PlantSimEngine.inputs_(::HardDepSameScaleEchelle1Model) - (a=-Inf, e2=-Inf)# e3 = -Inf, f3 = -Inf) -end - -function PlantSimEngine.outputs_(::HardDepSameScaleEchelle1Model) - (e1=-Inf, f1=-Inf) -end - -#PlantSimEngine.dep(::HardDepSameScaleEchelle1Model) = (hard_dep_same_scale_echelle3=AbstractHard_Dep_Same_Scale_Echelle3Model => (:E3,),) - -# exta_args = sim_object -function PlantSimEngine.run!(::HardDepSameScaleEchelle1Model, models, status, meteo, constants=nothing, sim_object=nothing) - #run!(sim_object.models[:E3].hard_dep_same_scale_echelle3, models, status, meteo, constants) - status.e1 = 1.0#status.e3 - #status.e3 = status.e3 * 2.0 - status.f1 = status.a #status.e3 * 2.0 + status.f3 + status.a - #status.c = 1.0 + status.e2 -end - -################# - -struct HardDepSameScaleEchelle1bisModel <: AbstractHard_Dep_Same_Scale_Echelle1BisModel -end - -function PlantSimEngine.inputs_(::HardDepSameScaleEchelle1bisModel) - (e3=-Inf,) -end - -function PlantSimEngine.outputs_(::HardDepSameScaleEchelle1bisModel) - (e2=-Inf, f2=-Inf) -end - -PlantSimEngine.dep(::HardDepSameScaleEchelle1bisModel) = (hard_dep_same_scale_echelle3=AbstractHard_Dep_Same_Scale_Echelle3Model => (:E3,),) - -# exta_args = sim_object -function PlantSimEngine.run!(::HardDepSameScaleEchelle1bisModel, models, status, meteo, constants=nothing, sim_object=nothing) - status_E3 = sim_object.statuses[:E3][1] - run!(sim_object.models[:E3].hard_dep_same_scale_echelle3, models, status_E3, meteo, constants) - status.e2 = status_E3.e3 - status_E3.e3 = status_E3.e3 * 2.0 - status.f2 = status_E3.e3 * 2.0 -end - -################# - -struct HardDepSameScaleAvalModel <: AbstractHard_Dep_Same_Scale_AvalModel -end - -function PlantSimEngine.inputs_(::HardDepSameScaleAvalModel) - (e3=-Inf,) # f1 or f2 ? -end - -function PlantSimEngine.outputs_(::HardDepSameScaleAvalModel) - (g=-Inf,) -end - -function PlantSimEngine.run!(::HardDepSameScaleAvalModel, models, status, meteo, constants=nothing, extra_args=nothing) - status.g = status.e3 - #status.h = status.e1 -end - -#################################################################### -# actual testset - -@testset "Soft dependency whose parent is a hard dependency of a parent at a different scale" begin - mapping = ModelMapping( - :E1 => (HardDepSameScaleEchelle1Model(), - MultiScaleModel( - model=HardDepSameScaleEchelle1bisModel(), - mapped_variables=[:e3 => :E3 => :e3], - ), - Status(a=1.0),), - :E3 => ( - HardDepSameScaleEchelle3Model(), - HardDepSameScaleAvalModel(), Status(d=1.0,), - ), - ) - - @test to_initialize(mapping) == Dict() - - outs = Dict( - :E1 => (:e1, :f1, :e2, :f2), - :E3 => (:e3,) - ) - - meteo = Weather([ - Atmosphere(T=25.0, Wind=1.0, Rh=0.6, Ri_PAR_f=200.0), - Atmosphere(T=10.0, Wind=0.5, Rh=0.6, Ri_PAR_f=200.0)]) - - mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :E1, 0, 0),) - Node(mtg, MultiScaleTreeGraph.NodeMTG("/", :E3, 0, 1)) - - #sim = @test_nowarn PlantSimEngine.run!(mtg, mapping, meteo, tracked_outputs=outs, executor=SequentialEx()) - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg, mapping, nsteps=nsteps, check=false, outputs=outs) - out = run!(sim, meteo) - - model_1 = last(collect(sim.dependency_graph.roots)[1]) - - # Downscale soft dependency aval should point to the root node 1bis, instead of the 'real parent' 3, which is an inner hard dependency to 1bis - # so 1 and aval both point to 1bis - @test length(model_1.children) == 2 -end - - -################################################################################################################################################################################# - -####################################################################################################################### -## 2 different scales that make use of the *same* model -####################################################################################################################### - -PlantSimEngine.@process "single_model_multiple_scales" verbose = false - -struct SingleModelScale1 <: AbstractSingle_Model_Multiple_ScalesModel -end -struct SingleModelScale2 <: AbstractSingle_Model_Multiple_ScalesModel -end -struct SingleModelScale2bis <: AbstractSingle_Model_Multiple_ScalesModel -end -struct SingleModelScale3 <: AbstractSingle_Model_Multiple_ScalesModel -end - -function PlantSimEngine.inputs_(::SingleModelScale1) - (in=-Inf, in1=-Inf) -end -function PlantSimEngine.outputs_(::SingleModelScale1) - (out=-Inf, out1=-Inf) -end - -function PlantSimEngine.inputs_(::SingleModelScale2) - (in=-Inf, in2=-Inf) -end -function PlantSimEngine.outputs_(::SingleModelScale2) - (out=-Inf, out2=-Inf) -end - -function PlantSimEngine.inputs_(::SingleModelScale2bis) - (in=-Inf, in2bis=-Inf) -end -function PlantSimEngine.outputs_(::SingleModelScale2bis) - (out=-Inf, out2bis=-Inf) -end - -function PlantSimEngine.inputs_(::SingleModelScale3) - (in=-Inf, in3=-Inf, out2=-Inf, out1=-Inf) -end -function PlantSimEngine.outputs_(::SingleModelScale3) - (out=-Inf, out3=-Inf) -end - -PlantSimEngine.dep(::SingleModelScale1) = (single_model_multiple_scales=AbstractSingle_Model_Multiple_ScalesModel => (:E2bis, :E2),) - -# extra_args = sim_object -function PlantSimEngine.run!(::SingleModelScale1, models, status, meteo, constants=nothing, sim_object=nothing) - status_E2 = sim_object.statuses[:E2][1] - status_E2b = sim_object.statuses[:E2bis][1] - run!(sim_object.models[:E2].single_model_multiple_scales, models, status_E2, meteo, constants) - run!(sim_object.models[:E2bis].single_model_multiple_scales, models, status_E2b, meteo, constants) - status.out = status_E2.out + status_E2b.out + status.in - status.out1 = status_E2.out2 + status_E2b.out2bis + status.out1 -end - -function PlantSimEngine.run!(::SingleModelScale2, models, status, meteo, constants=nothing, sim_object=nothing) - status.out = status.in + 1.0 - status.out2 = status.in2 -end - -function PlantSimEngine.run!(::SingleModelScale2bis, models, status, meteo, constants=nothing, sim_object=nothing) - status.out = status.in + 2.0 - status.out2bis = status.in2bis -end - -function PlantSimEngine.run!(::SingleModelScale3, models, status, meteo, constants=nothing, sim_object=nothing) - status.out = status.in + status.in3 + status.out2 - status.out3 = status.in3 + status.out1 -end - -############################# -## Actual testset - -@testset "Process/model reuse at different scales" begin - - mapping = ModelMapping( - :E1 => ( - SingleModelScale1(), - Status(in=1.0, in1=1.0), - ), - :E2 => ( - SingleModelScale2(), - Status(in=1.0, in2=1.0), - ), - :E2bis => ( - SingleModelScale2bis(), - Status(in=1.0, in2bis=1.0), - ), - :E3 => ( - MultiScaleModel( - model=SingleModelScale3(), - mapped_variables=[:out1 => :E1 => :out1, :out2 => :E2 => :out2,], - ), - Status(in=1.0, in3=1.0,), - ), - ) - - @test to_initialize(mapping) == Dict() - - outs = Dict( - :E1 => (:out, :out1), - :E2 => (:out, :out2), - :E2bis => (:out,), # comment this line out, and remove nodes relating to E2 and E2bis to expose the issue in #103 - :E3 => (:out3,) - ) - - meteo = Weather([ - Atmosphere(T=25.0, Wind=1.0, Rh=0.6, Ri_PAR_f=200.0), - Atmosphere(T=10.0, Wind=0.5, Rh=0.6, Ri_PAR_f=200.0)]) - - mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :E1, 0, 0),) - Node(mtg, MultiScaleTreeGraph.NodeMTG("/", :E3, 0, 1)) - Node(mtg, MultiScaleTreeGraph.NodeMTG("/", :E2, 0, 2)) - Node(mtg, MultiScaleTreeGraph.NodeMTG("/", :E2bis, 0, 3)) - - #sim = @test_nowarn PlantSimEngine.run!(mtg, mapping, meteo, tracked_outputs=outs, executor = SequentialEx()) - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg, mapping, nsteps=nsteps, check=false, outputs=outs) - out = run!(sim, meteo) - - roots = sim.dependency_graph.roots - @test length(sim.dependency_graph.roots) == 1 - - model_1 = last(collect(roots)[1]) - - @test length(model_1.children) == 1 - @test length(model_1.hard_dependency) == 2 - @test model_1.children[1].parent[1] == model_1 - @test model_1.hard_dependency[1].parent == model_1 - @test model_1.hard_dependency[2].parent == model_1 - -end - - - -########################## -## No outputs when simulating a mapping with one meteo timestep #105 -########################## - -@testset "Issue 105 : no outputs when simulating a mapping with one meteo timestep" begin - - using PlantSimEngine, PlantMeteo, DataFrames - using PlantSimEngine.Examples - mtg = import_mtg_example() - m = ModelMapping( - :Leaf => ( - Process1Model(1.0), - Status(var1=10.0, var2=1.0,) - ) - ) - - @test to_initialize(m) == Dict() - - vars = Dict{Symbol,Any}(:Leaf => (:var1,)) - out = run!(mtg, m, Atmosphere(T=20.0, Wind=1.0, Rh=0.65), tracked_outputs=vars, executor=SequentialEx()) - df_dict = convert_outputs(out, DataFrame) - @test DataFrames.nrow(df_dict[:Leaf]) == 2 - @test DataFrames.ncol(df_dict[:Leaf]) == 3 -end - -########################## -## Multiscale : outputs not saved when dependency graph only has one depth level #111 -########################## - -# Probably very similar to #105 -@testset "Issue 111 : Multiscale : outputs not saved when dependency graph only has one depth level" begin - - using PlantSimEngine - using PlantSimEngine.Examples - using MultiScaleTreeGraph - - status2 = (var1=15.0, var2=0.3) - - meteo = Weather([ - Atmosphere(T=25.0, Wind=1.0, Rh=0.6, Ri_PAR_f=200.0), - Atmosphere(T=10.0, Wind=0.5, Rh=0.6, Ri_PAR_f=200.0)]) - - outs = Dict(:Default => (:var1,)) - mtg = MultiScaleTreeGraph.Node(MultiScaleTreeGraph.NodeMTG("/", :Default, 0, 0),) - - mapping = ModelMapping( - :Default => ( - Process1Model(1.0), - Status(var1=15.0, var2=0.3,), - ), - ) - @test to_initialize(mapping) == Dict() - - sim = run!(mtg, mapping, meteo; tracked_outputs=outs) - using DataFrames - df_dict = convert_outputs(sim, DataFrame) - @test DataFrames.nrow(df_dict[:Default]) == PlantSimEngine.get_nsteps(meteo) -end - - -############################################ -### #86 : BoundsError with a single model and several Weather timesteps -############################################ - -using PlantSimEngine -PlantSimEngine.@process "toy" verbose = false - -""" -Inputs : a, b, c -Outputs : d, e -""" - -struct ToyToyModel{T} <: AbstractToyModel - internal_constant::T -end - -function PlantSimEngine.inputs_(::ToyToyModel) - (a=-Inf, b=-Inf, c=-Inf) -end - -# note : here, d is set with = further down, but e is set with +=, ie inf + thingy, is this a bug on my end ? -function PlantSimEngine.outputs_(::ToyToyModel) - (d=-Inf, e=-Inf) -end - -function PlantSimEngine.run!(m::ToyToyModel, models, status, meteo, constants=nothing, extra_args=nothing) - status.d = m.internal_constant * status.a - status.e += m.internal_constant -end - -@testset "Issue #86 : BoundsError with a single model and several Weather timesteps" begin - meteo = Weather([ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f=200.0), - Atmosphere(T=18.0, Wind=1.0, Rh=0.65, Ri_PAR_f=100.0), - ]) - - model = ModelMapping( - ToyToyModel(1), - status=(a=1, b=0, c=0), - #nsteps = length(meteo) - ) - @test to_initialize(model) == NamedTuple() - - sim = run!(model, meteo) - @test DataFrames.nrow(sim) == PlantSimEngine.get_nsteps(meteo) -end diff --git a/test/test-dimensions.jl b/test/test-dimensions.jl deleted file mode 100644 index 01eeae5a7..000000000 --- a/test/test-dimensions.jl +++ /dev/null @@ -1,48 +0,0 @@ -@testset "Chech status and weather correspond" begin - st = Status(Ra_SW_f=13.747, sky_fraction=1.0, d=0.03, aPPFD=1500) - tst1 = TimeStepTable([st]) - tst2 = TimeStepTable([st, st]) - tst3 = TimeStepTable([st, st, st]) - - atm = Atmosphere(T=25.0, Wind=5.0, Rh=0.3) - w1 = Weather([atm]) - w2 = Weather([atm, atm]) - w3 = Weather([atm, atm, atm]) - - # Status and Atmosphere are always authorized - @test PlantSimEngine.check_dimensions(st, atm) === nothing - - # TimeStepTable and Atmosphere are always authorized - # @test PlantSimEngine.check_dimensions(tst1, atm) === nothing - # @test PlantSimEngine.check_dimensions(tst2, atm) === nothing - - # Status and Weather are always authorized - @test PlantSimEngine.check_dimensions(st, w1) === nothing - @test PlantSimEngine.check_dimensions(st, w2) === nothing - - # TimeStepTable and Weather must be checked for equal length - # @test PlantSimEngine.check_dimensions(tst1, w1) === nothing - # @test PlantSimEngine.check_dimensions(tst2, w2) === nothing - - # This still works because one time step is recycled: - # @test PlantSimEngine.check_dimensions(tst1, w2) === nothing - - # @test_throws DimensionMismatch PlantSimEngine.check_dimensions(tst2, w1) - # @test_throws DimensionMismatch PlantSimEngine.check_dimensions(tst3, w2) - - # ModelMapping and Weather must be checked for equal length - m1 = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - status=(var1=1.0, var2=2.0) - ) - @test PlantSimEngine.check_dimensions(m1, w1) === nothing - - m2 = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - status=(var1=[1.0, 2.0], var2=2.0) - ) - @test PlantSimEngine.check_dimensions(m2, w2) === nothing - @test_throws DimensionMismatch PlantSimEngine.check_dimensions(m2, w1) -end \ No newline at end of file diff --git a/test/test-environment-backends.jl b/test/test-environment-backends.jl new file mode 100644 index 000000000..366a03fb5 --- /dev/null +++ b/test/test-environment-backends.jl @@ -0,0 +1,33 @@ +using Dates + +@testset "Environment backend contract" begin + support = EnvironmentSupport(:leaf_energy, :Leaf, :energy_balance, nothing) + backend = GlobalConstant( + Atmosphere( + T=20.0, + Rh=0.65, + Wind=1.0, + CO2=410.0, + duration=Dates.Hour(1), + ), + ) + + @test sample(backend, :T, support, 1.0) == 20.0 + @test PlantSimEngine.get_nsteps(backend) == 1 + @test base_step_seconds(backend) == 3600.0 + @test environment_variables(GlobalConstant(nothing)) == Set{Symbol}() + + @test_throws "leaf_energy/Leaf/energy_balance" sample( + backend, + :missing, + support, + 1.0, + ) + @test_throws "GlobalConstant is immutable" scatter!( + backend, + :T, + support, + 21.0, + 1.0, + ) +end diff --git a/test/test-fitting.jl b/test/test-fitting.jl index 54a0337a4..608e41c10 100644 --- a/test/test-fitting.jl +++ b/test/test-fitting.jl @@ -1,14 +1,32 @@ +using Dates + # Tests: # Defining a list of models without status: @testset "Fitting Beer" begin k = 0.6 - meteo = Atmosphere(T=20.0, Wind=1.0, P=101.3, Rh=0.65, Ri_PAR_f=300.0) - m = ModelMapping(Beer(k), status=(LAI=2.0,)) - outs = run!(m, meteo) - - df = DataFrame(aPPFD=outs[:aPPFD][1], LAI=m.status.LAI[1], Ri_PAR_f=meteo.Ri_PAR_f[1]) + meteo = Atmosphere( + T=20.0, + Wind=1.0, + P=101.3, + Rh=0.65, + Ri_PAR_f=300.0, + duration=Dates.Hour(1), + ) + model = CompositeModel( + Object(:leaf; scale=:Leaf, status=Status(LAI=2.0)); + applications=( + ModelSpec(Beer(k)) |> AppliesTo(One(scale=:Leaf)), + ), + environment=meteo, + ) + run!(model) + leaf = only(model_objects(model; scale=:Leaf)) + df = DataFrame( + aPPFD=[leaf.status.aPPFD], + LAI=[leaf.status.LAI], + Ri_PAR_f=[meteo.Ri_PAR_f[1]], + ) k_fit = fit(PlantSimEngine.Examples.Beer, df).k @test k_fit == k -end; - +end diff --git a/test/test-maespa-model-example.jl b/test/test-maespa-model-example.jl new file mode 100644 index 000000000..5cf02a031 --- /dev/null +++ b/test/test-maespa-model-example.jl @@ -0,0 +1,249 @@ +include("../examples/maespa_model_example.jl") + +@testset "MAESPA-style model example" begin + result = run_maespa_example(; nhours=25, check=true) + model = result.model + compiled = result.compiled + + @test length(model_objects(model; scale=:Leaf)) == 5 + @test length(model_objects(model; scale=:Leaf, species=:A)) == 2 + @test length(model_objects(model; scale=:Leaf, species=:B)) == 3 + @test length(model_objects(model; scale=:Plant)) == 2 + @test length(model_objects(model; kind=:soil)) == 1 + + instance_rows = explain_instances(model) + @test Set(row.name for row in instance_rows) == Set([:plant_A, :plant_B]) + plant_a_instance = only(row for row in instance_rows if row.name == :plant_A) + plant_b_instance = only(row for row in instance_rows if row.name == :plant_B) + @test plant_a_instance.object_ids == [:plant_A, :plant_A_axis, :plant_A_leaf_1, :plant_A_leaf_2] + @test plant_b_instance.object_ids == [:plant_B, :plant_B_axis, :plant_B_leaf_1, :plant_B_leaf_2, :plant_B_leaf_3] + @test :plant_A__energy_balance in plant_a_instance.application_ids + @test :plant_B__allocation in plant_b_instance.application_ids + + calls = explain_calls(compiled) + scene_energy_call = only(row for row in calls if row.application_id == :scene_eb && row.call == :energy_balance) + @test scene_energy_call.callee_object_ids == [ + :plant_A_leaf_1, + :plant_A_leaf_2, + :plant_B_leaf_1, + :plant_B_leaf_2, + :plant_B_leaf_3, + ] + @test Set(scene_energy_call.callee_application_ids) == Set([:plant_A__energy_balance, :plant_B__energy_balance]) + @test scene_energy_call.publication_policy == :explicit_accept + @test !scene_energy_call.default_publish + @test scene_energy_call.accepted_publish + scene_soil_call = only(row for row in calls if row.application_id == :scene_eb && row.call == :soil) + @test scene_soil_call.callee_object_ids == [:soil] + @test scene_soil_call.callee_application_ids == [:soil_water] + @test count(row -> row.call == :photosynthesis, calls) == 5 + @test count(row -> row.call == :stomatal_conductance, calls) == 5 + + leaf_energy_bundle = only( + row for row in explain_model_bundles(compiled) + if row.application_id == :plant_A__energy_balance && + row.object_id == :plant_A_leaf_1 + ) + @test leaf_energy_bundle.processes == [ + :energy_balance, + :photosynthesis, + :stomatal_conductance, + ] + @test leaf_energy_bundle.model_types == [Monteith{Float64,Int64}, Fvcb{Float64}, Tuzet{Float64}] + + bindings = explain_bindings(compiled) + lai_binding = only(row for row in bindings if row.application_id == :lai_dynamic && row.input == :leaf_areas) + @test lai_binding.carrier_kind == :ref_vector + @test lai_binding.copy_semantics == :live_references + @test lai_binding.source_ids == scene_energy_call.callee_object_ids + plant_a_allocation_binding = only(row for row in bindings if row.application_id == :plant_A__allocation) + plant_b_allocation_binding = only(row for row in bindings if row.application_id == :plant_B__allocation) + @test plant_a_allocation_binding.source_ids == [:plant_A_leaf_1, :plant_A_leaf_2] + @test plant_b_allocation_binding.source_ids == [:plant_B_leaf_1, :plant_B_leaf_2, :plant_B_leaf_3] + expected_scene_leaf_inputs = ( + leaf_areas=:leaf_area, + leaf_carbon=:leaf_carbon, + leaf_Ra_SW_f=:Ra_SW_f, + leaf_aPPFD=:aPPFD, + Ψₗ=:Ψₗ, + leaf_rn=:Rn, + leaf_lambda_e=:λE, + leaf_h=:H, + leaf_a=:A, + ) + for (input, source_var) in pairs(expected_scene_leaf_inputs) + binding = only( + row for row in bindings + if row.application_id == :scene_eb && row.input == input + ) + @test binding.source_ids == scene_energy_call.callee_object_ids + @test binding.source_var == source_var + @test binding.carrier_kind == :ref_vector + @test binding.copy_semantics == :live_references + end + scene_psi_binding = only( + row for row in bindings + if row.application_id == :scene_eb && row.input == :psi_soil + ) + @test scene_psi_binding.source_ids == [:soil] + @test scene_psi_binding.source_application_ids == [:soil_water] + @test scene_psi_binding.source_var == :psi_soil + @test scene_psi_binding.carrier_kind == :ref + @test scene_psi_binding.copy_semantics == :live_references + soil_transpiration_binding = only( + row for row in bindings + if row.application_id == :soil_water && row.input == :transpiration + ) + @test soil_transpiration_binding.source_ids == [:model] + @test soil_transpiration_binding.source_application_ids == [:scene_eb] + @test soil_transpiration_binding.source_var == :scene_transpiration + @test soil_transpiration_binding.carrier_kind == :ref + @test soil_transpiration_binding.copy_semantics == :live_references + soil_infiltration_binding = only( + row for row in bindings + if row.application_id == :soil_water && row.input == :infiltration + ) + @test soil_infiltration_binding.source_ids == [:model] + @test soil_infiltration_binding.source_application_ids == [:scene_eb] + @test soil_infiltration_binding.source_var == :scene_infiltration + @test soil_infiltration_binding.carrier_kind == :ref + @test soil_infiltration_binding.copy_semantics == :live_references + + scene_object = only(model_objects(model; scale=:Scene)) + soil_object = only(model_objects(model; kind=:soil)) + for input in keys(expected_scene_leaf_inputs) + compiled_binding = only( + binding for binding in compiled.input_bindings + if binding.application_id == :scene_eb && binding.input == input + ) + @test getproperty(scene_object.status, input) === input_carrier(compiled_binding) + end + compiled_scene_psi_binding = only( + binding for binding in compiled.input_bindings + if binding.application_id == :scene_eb && binding.input == :psi_soil + ) + @test PlantSimEngine.refvalue(scene_object.status, :psi_soil) === + input_carrier(compiled_scene_psi_binding) + @test input_carrier(compiled_scene_psi_binding) === + PlantSimEngine.refvalue(soil_object.status, :psi_soil) + compiled_soil_transpiration_binding = only( + binding for binding in compiled.input_bindings + if binding.application_id == :soil_water && binding.input == :transpiration + ) + @test PlantSimEngine.refvalue(soil_object.status, :transpiration) === + input_carrier(compiled_soil_transpiration_binding) + @test input_carrier(compiled_soil_transpiration_binding) === + PlantSimEngine.refvalue(scene_object.status, :scene_transpiration) + compiled_soil_infiltration_binding = only( + binding for binding in compiled.input_bindings + if binding.application_id == :soil_water && binding.input == :infiltration + ) + @test PlantSimEngine.refvalue(soil_object.status, :infiltration) === + input_carrier(compiled_soil_infiltration_binding) + @test input_carrier(compiled_soil_infiltration_binding) === + PlantSimEngine.refvalue(scene_object.status, :scene_infiltration) + + schedule = explain_schedule(compiled) + @test only(row for row in schedule if row.application_id == :scene_eb).root_scheduled + @test only(row for row in schedule if row.application_id == :plant_A__energy_balance).manual_call_only + @test only(row for row in schedule if row.application_id == :soil_water).manual_call_only + @test only(row for row in schedule if row.application_id == :plant_A__allocation).dt_steps == 24.0 + @test only(row for row in schedule if row.application_id == :lai_dynamic).dt_steps == 24.0 + + scene_simulation = result.simulation + @test scene_simulation isa Simulation + output_rows = collect_outputs(scene_simulation; sink=nothing) + @test count(row -> row.variable == :λE, output_rows) == 5 * 25 + @test count(row -> row.object_id == :soil && row.variable == :psi_soil, output_rows) == 25 + @test count(row -> row.object_id == :model && row.variable == :scene_transpiration, output_rows) == 25 + @test count(row -> row.object_id == :model && row.variable == :scene_infiltration, output_rows) == 25 + @test count(row -> row.object_id == :model && row.variable == :lai, output_rows) == 2 + @test count(row -> row.variable == :daily_growth, output_rows) == 2 * 2 + output_summary = explain_outputs(scene_simulation) + @test only(row for row in output_summary if row.object_id == :model && row.variable == :scene_transpiration).nsamples == 25 + @test only(row for row in output_summary if row.object_id == :plant_A_leaf_1 && row.variable == :λE).application_id == :plant_A__energy_balance + + scene_status = only(model_objects(model; scale=:Scene)).status + last_meteo = last(maespa_meteo(; nhours=25)) + vpd_above = max(0.01, last_meteo.VPD) + @test isfinite(scene_status.canopy_tair) + @test isfinite(scene_status.canopy_vpd) + @test isfinite(scene_status.canopy_rh) + @test scene_status.canopy_tair >= last_meteo.T - 10.0 + @test scene_status.canopy_tair <= last_meteo.T + 10.0 + @test scene_status.canopy_vpd >= max(0.01, vpd_above - 1.5) + @test scene_status.canopy_vpd <= vpd_above + 1.5 + @test scene_status.scene_transpiration > 0.0 + @test scene_status.iterations > 0 + + leaf_statuses = [object.status for object in model_objects(model; scale=:Leaf)] + @test scene_status.leaf_area ≈ sum(st.leaf_area for st in leaf_statuses) + @test scene_status.lai ≈ scene_status.leaf_area + @test collect(scene_status.leaf_areas) ≈ getproperty.(leaf_statuses, :leaf_area) + @test collect(scene_status.leaf_carbon) ≈ getproperty.(leaf_statuses, :leaf_carbon) + @test collect(scene_status.leaf_Ra_SW_f) ≈ getproperty.(leaf_statuses, :Ra_SW_f) + @test collect(scene_status.leaf_aPPFD) ≈ getproperty.(leaf_statuses, :aPPFD) + @test collect(scene_status.Ψₗ) ≈ getproperty.(leaf_statuses, :Ψₗ) + @test collect(scene_status.leaf_rn) ≈ getproperty.(leaf_statuses, :Rn) + @test collect(scene_status.leaf_lambda_e) ≈ getproperty.(leaf_statuses, :λE) + @test collect(scene_status.leaf_h) ≈ getproperty.(leaf_statuses, :H) + @test collect(scene_status.leaf_a) ≈ getproperty.(leaf_statuses, :A) + @test all(st -> isfinite(st.Tₗ), leaf_statuses) + @test all(st -> isfinite(st.A), leaf_statuses) + @test all(st -> isfinite(st.λE), leaf_statuses) + @test any(st -> abs(st.Tₗ - scene_status.canopy_tair) > 1.0e-6, leaf_statuses) + + plant_a_status = only(model_objects(model; name=:plant_A)).status + plant_b_status = only(model_objects(model; name=:plant_B)).status + @test plant_a_status.daily_growth > 0.0 + @test plant_b_status.daily_growth > 0.0 + @test plant_a_status.daily_growth != plant_b_status.daily_growth + @test plant_a_status.leaf_pool != plant_b_status.leaf_pool + + soil_status = only(model_objects(model; kind=:soil)).status + @test soil_status.transpiration ≈ scene_status.scene_transpiration + @test soil_status.infiltration ≈ scene_status.scene_infiltration + @test soil_status.psi_soil ≈ scene_status.psi_soil +end + +@testset "MAESPA-style model example validation" begin + meteo = maespa_meteo(; nhours=1) + + scene_status = Status(lai=0.0, leaf_area=0.0, psi_soil=-0.1) + @test_throws "SceneEB did not converge after 0 iterations" _solve_model_energy_balance!( + SceneEB(0, 0.03, 0.005), + CallTarget[], + scene_status, + first(meteo), + ) +end + +@testset "MAESPA-style canopy helper functions" begin + lai_model = LAIModel(2.0) + lai_status = Status(leaf_areas=[0.5, 1.0], leaf_area=0.0, lai=0.0) + PlantSimEngine.run!(lai_model, nothing, lai_status, nothing, nothing) + @test lai_status.leaf_area ≈ 1.5 + @test lai_status.lai ≈ 0.75 + + m = SceneEB(25, 0.03, 0.005; ground_area=2.0) + + low_wind = gbcanms(0.0, m.zht, m.tree_height; gbcan_min=m.gbcan_min, von_karman=m.von_karman) + @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)) + hot_fluxes = (rn=5000.0, lambda_e=-5000.0, a=0.0, lai=0.75, rad_interc=0.0) + hot = tvpdcanopcalc(m, hot_fluxes, meteo_above, canopy_meteo, PlantMeteo.Constants()) + @test hot.tair <= meteo_above.T + 10.0 + @test hot.vpd <= max(0.01, meteo_above.VPD) + 1.5 + + wet_fluxes = (rn=-5000.0, lambda_e=5000.0, a=0.0, lai=0.75, rad_interc=0.0) + wet = tvpdcanopcalc(m, wet_fluxes, meteo_above, canopy_meteo, PlantMeteo.Constants()) + @test wet.tair >= meteo_above.T - 10.0 + @test wet.vpd >= max(0.01, meteo_above.VPD - 1.5) + @test wet.vpd >= 0.01 + @test 0.0 <= wet.rh <= 1.0 + @test meteo_above.T == 25.0 + @test max(0.01, meteo_above.VPD) == meteo_above.VPD +end diff --git a/test/test-mapping.jl b/test/test-mapping.jl deleted file mode 100755 index 09c9a6e2b..000000000 --- a/test/test-mapping.jl +++ /dev/null @@ -1,384 +0,0 @@ -mapping = ModelMapping( - :Plant => ( - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - # inputs - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - # outputs - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - ), - :Internode => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(TT=10.0) - ), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], - # Notice we provide :Soil, not [:Soil], so a single value is expected here - ), - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - Status(aPPFD=1300.0, TT=10.0), - ), - :Soil => ( - ToySoilWaterModel(), - ), -) - -dep_graph = dep(mapping) - -# The C allocation depends on the C demand at the leaf and internode levels, -# the maintenance respiration at the plant level, and the maintenance respiration at the plant level, -# which depends on the maintenance respiration at the leaf and internode levels. - -# Expected root dependency nodes: -root_models = Dict( - (:Soil => :soil_water) => mapping[:Soil][1], # The only model from the soil is completely independent - (:Internode => :carbon_demand) => mapping[:Internode][1], # The c allocation models dependent on TT, that is given as input: - (:Leaf => :carbon_demand) => mapping[:Leaf][2], # Same for the leaf - (:Internode => :maintenance_respiration) => mapping[:Internode][2], # The maintenance respiration model for the internode is independant - (:Leaf => :maintenance_respiration) => mapping[:Leaf][3], # The maintenance respiration model for the leaf is independant -) - -for (proc, node) in dep_graph.roots # proc = (:Soil => :soil_water) ; node = dep_graph.roots[proc] - @test root_models[proc] == node.value -end - -@testset "ModelMapping checks and normalization" begin - mapping_struct = PlantSimEngine.ModelMapping(Dict(mapping)) - @test mapping_struct isa PlantSimEngine.ModelMapping - @test Set(keys(mapping_struct)) == Set(keys(mapping)) - @test hasmethod(PlantSimEngine.dep, Tuple{PlantSimEngine.ModelMapping}) - @test hasmethod(PlantSimEngine.hard_dependencies, Tuple{PlantSimEngine.ModelMapping}) - @test hasmethod(PlantSimEngine.inputs, Tuple{PlantSimEngine.ModelMapping}) - @test hasmethod(PlantSimEngine.outputs, Tuple{PlantSimEngine.ModelMapping}) - @test hasmethod(PlantSimEngine.variables, Tuple{PlantSimEngine.ModelMapping}) - @test hasmethod(PlantSimEngine.to_initialize, Tuple{PlantSimEngine.ModelMapping}) - @test hasmethod(PlantSimEngine.reverse_mapping, Tuple{PlantSimEngine.ModelMapping}) - - mapping_from_pairs = PlantSimEngine.ModelMapping( - :Plant => mapping[:Plant], - :Internode => mapping[:Internode], - :Leaf => mapping[:Leaf], - :Soil => mapping[:Soil], - ) - @test Set(keys(mapping_from_pairs)) == Set(keys(mapping)) - - mapping_with_specs = PlantSimEngine.ModelMapping( - :Scene => (ModelSpec(ToyDegreeDaysCumulModel()) |> TimeStepModel(ClockSpec(24.0, 1.0)),), - :Soil => (ModelSpec(ToySoilWaterModel()) |> TimeStepModel(ClockSpec(24.0, 1.0)),), - :Leaf => ( - ModelSpec(ToyAssimModel()) |> - MultiScaleModel([:soil_water_content => (:Soil => :soil_water_content)]) |> - TimeStepModel(1.0), - ), - ) - @test mapping_with_specs isa PlantSimEngine.ModelMapping - @test any(item -> item isa ModelSpec, mapping_with_specs[:Soil]) - @test mapping_with_specs.info.validated - @test mapping_with_specs.info.is_valid - @test mapping_with_specs.info.is_multirate - @test Set(mapping_with_specs.info.scales) == Set([:Scene, :Soil, :Leaf]) - @test mapping_with_specs.info.models_per_scale[:Leaf] == 1 - @test length(mapping_with_specs.info.processes_per_scale[:Leaf]) == 1 - @test haskey(mapping_with_specs.info.model_specs, :Leaf) - - io = IOBuffer() - show(io, MIME("text/plain"), mapping_with_specs) - summary_txt = String(take!(io)) - @test occursin("ModelMapping", summary_txt) - @test occursin("multirate: true", summary_txt) - @test occursin("scales (3)", summary_txt) - - dep_from_dict = dep(mapping) - dep_from_struct = dep(mapping_struct) - @test Set(keys(dep_from_dict.roots)) == Set(keys(dep_from_struct.roots)) - @test Set(keys(first(PlantSimEngine.hard_dependencies(mapping_struct)).roots)) == Set(keys(first(PlantSimEngine.hard_dependencies(Dict(mapping_struct))).roots)) - @test inputs(mapping_struct) == inputs(Dict(mapping_struct)) - @test outputs(mapping_struct) == outputs(Dict(mapping_struct)) - @test variables(mapping_struct) == variables(Dict(mapping_struct)) - - ModelMapping_scale = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - status=(var1=1.0, var2=2.0) - ) - merged_mapping = PlantSimEngine.ModelMapping(Dict(:Default => ModelMapping_scale)) - @test length(PlantSimEngine.get_models(merged_mapping[:Default])) == 2 - @test !isnothing(PlantSimEngine.get_status(merged_mapping[:Default])) - - single_scale_from_models = PlantSimEngine.ModelMapping( - Process1Model(1.0), - Process2Model(); - scale=:Default, - status=(var1=1.0, var2=2.0), - ) - @test Set(keys(single_scale_from_models)) == Set([:Default]) - @test length(PlantSimEngine.get_models(single_scale_from_models[:Default])) == 2 - @test PlantSimEngine.get_status(single_scale_from_models[:Default]).var1 == 1.0 - - single_scale_from_namedtuple = PlantSimEngine.ModelMapping( - (process1=Process1Model(1.0), process2=Process2Model()); - status=(var1=1.0, var2=2.0), - ) - @test Set(keys(single_scale_from_namedtuple)) == Set([:Default]) - @test length(PlantSimEngine.get_models(single_scale_from_namedtuple[:Default])) == 2 - - single_scale_from_kwargs = PlantSimEngine.ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - status=(var1=1.0, var2=2.0), - ) - @test Set(keys(single_scale_from_kwargs)) == Set([:Default]) - @test length(PlantSimEngine.get_models(single_scale_from_kwargs[:Default])) == 2 - - @test_throws "Cannot mix scale-level pairs" PlantSimEngine.ModelMapping( - :Leaf => (Process1Model(1.0),), - process2=Process2Model(), - ) - - missing_scale_mapping = Dict( - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content)], - ), - ), - ) - @test_throws "missing scale `Soil`" PlantSimEngine.ModelMapping(missing_scale_mapping) - - missing_source_variable_mapping = Dict( - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content)], - ), - ), - :Soil => ( - Process1Model(1.0), - ), - ) - @test_throws "not available at scale `Soil`" PlantSimEngine.ModelMapping(missing_source_variable_mapping) - - no_model_mapping = Dict( - :Soil => (Status(soil_water_content=0.2),), - ) - @test_throws "defines no model" PlantSimEngine.ModelMapping(no_model_mapping) - - duplicate_process_mapping = Dict( - :Leaf => ( - Process1Model(1.0), - Process1Model(2.0), - ), - ) - @test_throws "duplicate process(es)" PlantSimEngine.ModelMapping(duplicate_process_mapping) - - meteo = Atmosphere(T=20.0, Wind=1.0, Rh=0.65) - models_single_scale = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=15.0, var2=0.3) - ) - @test !models_single_scale.info.is_multirate - @test models_single_scale.info.scales == [:Default] - @test models_single_scale.info.models_per_scale[:Default] == 3 - baseline_outputs = run!(models_single_scale, meteo) - - outputs_from_models_args = run!( - PlantSimEngine.ModelMapping( - Process1Model(1.0), - Process2Model(), - Process3Model(); - status=(var1=15.0, var2=0.3), - ), - meteo - ) - @test outputs_from_models_args == baseline_outputs - - outputs_from_named_tuple = run!( - PlantSimEngine.ModelMapping( - (process1=Process1Model(1.0), process2=Process2Model(), process3=Process3Model()); - status=(var1=15.0, var2=0.3), - ), - meteo - ) - @test outputs_from_named_tuple == baseline_outputs - - outputs_from_kwargs = run!( - PlantSimEngine.ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=15.0, var2=0.3), - ), - meteo - ) - @test outputs_from_kwargs == baseline_outputs - - @test_throws "Use `run!(mtg, mapping, ...)` for multiscale mappings." run!(mapping, meteo) -end - - - -########################### -### Single and multi-scale ModelMapping comparison -### and Mapping with custom models vs mapping with generated models for user-provided vector -########################### - -# Currently untested in 'real' multi-scale modes, or with complex configs (hard dependencies). -# Need to place the simple timestep models in PlantSimEngine, and probably provide more complex ones at some point - -# And then need to insert it at the graph sim generation level, and modify tests to consistently do single <-> multiple scale conversions -# And then implement tests with proper output filtering - -@testset "check_statuses_contain_no_remaining_vectors behaviour" begin - meteo_day = CSV.read(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), DataFrame, header=18) - mapping_with_vector = ModelMapping( - :Scale => - (ToyAssimGrowthModel(0.0, 0.0, 0.0), - ToyCAllocationModel(), - Status(TT_cu=Vector(cumsum(meteo_day.TT))), - ), - ) - - mtg = import_mtg_example() - @test !last(PlantSimEngine.check_statuses_contain_no_remaining_vectors(mapping_with_vector)) - @test_throws "call the function generate_models_from_status_vectors" PlantSimEngine.GraphSimulation(mtg, mapping_with_vector) - - mapping_with_empty_status = ModelMapping( - :Scale => - (ToyAssimGrowthModel(0.0, 0.0, 0.0), - ToyCAllocationModel(), - Status(), - ), - ) - - @test last(PlantSimEngine.check_statuses_contain_no_remaining_vectors(mapping_with_empty_status)) -end - -@testset "Vector in status in a multiscale context" begin - meteo_day = CSV.read(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), DataFrame, header=18) - TT_v = Vector(meteo_day.TT) - TT_cu_vec = Vector(cumsum(meteo_day.TT)) - nsteps = length(meteo_day.TT) - - mapping_with_vector = ModelMapping(:Plant => ( - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - # inputs - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - # outputs - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - ), - :Internode => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(TT=TT_v, carbon_biomass=1.0) - ), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], - # Notice we provide :Soil, not [:Soil], so a single value is expected here - ), - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - Status(aPPFD=1300.0, carbon_biomass=2.0, TT=10.0), # TODO try calling the generated TT output through a variable mapping - ), - :Soil => ( - ToySoilWaterModel(), - ), - ) - - out_multiscale = Dict(:Plant => (:Rm_organs,),) - mtg = import_mtg_example() - - mapping_without_vectors = PlantSimEngine.replace_mapping_status_vectors_with_generated_models(mapping_with_vector, :Soil, nsteps) - - @test to_initialize(mapping_without_vectors) == Dict() - - graph_sim_multiscale = @test_nowarn PlantSimEngine.GraphSimulation(mtg, mapping_without_vectors, nsteps=nsteps, check=true, outputs=out_multiscale) - - sim_multiscale = run!(graph_sim_multiscale, - meteo_day, - PlantMeteo.Constants(), - nothing; - check=true, - executor=SequentialEx() - ) - - #replace a value with a constant vector and ensure no changes happen in the simulation - carbon_biomass_vec = Vector{Float64}(undef, nsteps) - for i in nsteps - carbon_biomass_vec[i] = 2.0 - end - mapping_with_two_vectors = ModelMapping(:Plant => ( - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - # inputs - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - # outputs - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - ), - :Internode => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(TT=TT_v, carbon_biomass=1.0) - ), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], - ), - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - Status(aPPFD=1300.0, carbon_biomass=carbon_biomass_vec, TT=10.0), # Replaced with vector here - ), - :Soil => ( - ToySoilWaterModel(), - ), - ) - - mtg = import_mtg_example() - mapping_without_vectors_2 = PlantSimEngine.replace_mapping_status_vectors_with_generated_models(mapping_with_two_vectors, :Soil, nsteps) - graph_sim_multiscale_2 = @test_nowarn PlantSimEngine.GraphSimulation(mtg, mapping_without_vectors_2, nsteps=nsteps, check=true, outputs=out_multiscale) - - @test to_initialize(mapping_without_vectors_2) == Dict() - - sim_multiscale_2 = run!(graph_sim_multiscale_2, - meteo_day, - PlantMeteo.Constants(), - nothing; - check=true, - executor=SequentialEx() - ) - - @test compare_outputs_graphsim(graph_sim_multiscale, graph_sim_multiscale_2) -end diff --git a/test/test-meteo-traits.jl b/test/test-meteo-traits.jl new file mode 100644 index 000000000..8b64c2ada --- /dev/null +++ b/test/test-meteo-traits.jl @@ -0,0 +1,52 @@ +using Dates + +PlantSimEngine.@process "meteo_trait_consumer" verbose = false + +struct MeteoTraitConsumerModel <: AbstractMeteo_Trait_ConsumerModel end + +PlantSimEngine.inputs_(::MeteoTraitConsumerModel) = NamedTuple() +PlantSimEngine.outputs_(::MeteoTraitConsumerModel) = (meteo_seen=0.0,) +PlantSimEngine.meteo_inputs_(::MeteoTraitConsumerModel) = (T=0.0, CO2=400.0) + +function PlantSimEngine.run!(::MeteoTraitConsumerModel, models, status, meteo, constants=nothing, extra=nothing) + status.meteo_seen = meteo.T + return nothing +end + +@testset "Meteo traits" begin + specs = Dict(:Leaf => Dict(:meteo_trait_consumer => ModelSpec(MeteoTraitConsumerModel()))) + + @test_throws "CO2" PlantSimEngine.validate_meteo_inputs( + specs, + Atmosphere(T=20.0, Rh=0.65, Wind=1.0, duration=Dates.Hour(1)) + ) + + @test PlantSimEngine.validate_meteo_inputs( + specs, + (T=20.0, CO2=410.0, duration=Dates.Hour(1)) + ) === nothing + + bound_spec = ModelSpec( + MeteoTraitConsumerModel(); + meteo_bindings=(CO2=(source=:Ca, reducer=MeanReducer()),), + ) + bound_specs = Dict(:Leaf => Dict(:meteo_trait_consumer => bound_spec)) + + @test_throws "Ca" PlantSimEngine.validate_meteo_inputs( + bound_specs, + (T=20.0, CO2=410.0, duration=Dates.Hour(1)) + ) + @test PlantSimEngine.validate_meteo_inputs( + bound_specs, + (T=20.0, Ca=410.0, duration=Dates.Hour(1)) + ) === nothing + + model = CompositeModel( + Object(:leaf; scale=:Leaf, status=Status(meteo_seen=0.0)); + applications=( + ModelSpec(MeteoTraitConsumerModel()) |> AppliesTo(One(scale=:Leaf)), + ), + environment=(T=20.0, CO2=410.0, duration=Dates.Hour(1)), + ) + @test validate_meteo_inputs(model) === nothing +end diff --git a/test/test-model-api-stabilization.jl b/test/test-model-api-stabilization.jl new file mode 100644 index 000000000..e5f81c7ee --- /dev/null +++ b/test/test-model-api-stabilization.jl @@ -0,0 +1,450 @@ +using Dates +using PlantSimEngine +using Test + +PlantSimEngine.@process "stabilization_source" verbose = false + +struct StabilizationSourceModel <: AbstractStabilization_SourceModel end + +PlantSimEngine.inputs_(::StabilizationSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::StabilizationSourceModel) = (signal=0.0,) + +function PlantSimEngine.run!( + ::StabilizationSourceModel, + models, + status, + meteo, + constants, + extra, +) + status.signal += 1 + return nothing +end + +PlantSimEngine.@process "stabilization_consumer" verbose = false + +struct StabilizationConsumerModel <: AbstractStabilization_ConsumerModel end + +PlantSimEngine.inputs_(::StabilizationConsumerModel) = (signal=0.0, supplied=0.0) +PlantSimEngine.outputs_(::StabilizationConsumerModel) = (observed=0.0,) + +function PlantSimEngine.run!( + ::StabilizationConsumerModel, + models, + status, + meteo, + constants, + extra, +) + status.observed = status.signal + status.supplied + return nothing +end + +PlantSimEngine.@process "stabilization_context" verbose = false + +struct StabilizationContextModel <: AbstractStabilization_ContextModel end + +PlantSimEngine.inputs_(::StabilizationContextModel) = NamedTuple() +PlantSimEngine.outputs_(::StabilizationContextModel) = (seen_revision=0,) + +function PlantSimEngine.run!( + ::StabilizationContextModel, + models, + status, + meteo, + constants, + extra, +) + status.seen_revision = Advanced.model_revision(runtime_model(extra)) + return nothing +end + +PlantSimEngine.@process "stabilization_environment" verbose = false + +struct StabilizationEnvironmentModel <: AbstractStabilization_EnvironmentModel end + +PlantSimEngine.inputs_(::StabilizationEnvironmentModel) = NamedTuple() +PlantSimEngine.outputs_(::StabilizationEnvironmentModel) = (temperature_seen=0.0,) +PlantSimEngine.meteo_inputs_(::StabilizationEnvironmentModel) = (T=0.0,) + +function PlantSimEngine.run!( + ::StabilizationEnvironmentModel, + models, + status, + meteo, + constants, + extra, +) + status.temperature_seen = meteo.T + return nothing +end + +@testset "one-object lowering and initialization report" begin + model = CompositeModel( + StabilizationSourceModel(), + StabilizationConsumerModel(); + status=(supplied=2.0,), + ) + + @test length(model_objects(model)) == 1 + @test length(model.applications) == 2 + @test only(model_objects(model)).id == ObjectId(:scene) + + timed_scene = CompositeModel( + StabilizationSourceModel(), + StabilizationConsumerModel(); + status=(supplied=2.0,), + timestep=Dates.Hour(2), + ) + @test all( + row -> row.timestep == Dates.Hour(2), + explain_applications(timed_scene), + ) + + explicit_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene, name=:scene, status=Status(supplied=2.0)); + applications=( + ModelSpec(StabilizationSourceModel()) |> AppliesTo(One(name=:scene)), + ModelSpec(StabilizationConsumerModel()) |> AppliesTo(One(name=:scene)), + ), + ) + concise_applications = explain_applications(model) + explicit_applications = explain_applications(explicit_scene) + @test getproperty.(concise_applications, :application_id) == + getproperty.(explicit_applications, :application_id) + @test getproperty.(concise_applications, :target_ids) == + getproperty.(explicit_applications, :target_ids) + + report = explain_initialization(model) + dispositions = Dict( + (row.application_id, row.variable, row.role) => row.disposition + for row in report + ) + @test dispositions[(:stabilization_source, :signal, :output)] == :generated + @test dispositions[(:stabilization_consumer, :signal, :input)] == + :producer_bound + @test dispositions[(:stabilization_consumer, :supplied, :input)] == :supplied + @test dispositions[(:stabilization_consumer, :observed, :output)] == :generated + supplied_row = only( + row for row in report + if row.application_id == :stabilization_consumer && + row.variable == :supplied + ) + @test supplied_row.origin == :status + @test supplied_row.expected_type == Float64 + @test supplied_row.provided_type == Float64 + + simulation = run!(model) + @test only(model_objects(model)).status.observed == 3.0 + @test runtime_model(model) === model + @test runtime_model(simulation) === model + @test length(explain_applications(simulation)) == 2 + @test length(explain_initialization(simulation)) == length(report) + @test !isempty(explain_execution_plan(simulation)) + + unresolved_scene = CompositeModel(StabilizationConsumerModel()) + unresolved = filter( + row -> row.role == :input && row.disposition == :unresolved, + explain_initialization(unresolved_scene), + ) + @test Set(row.variable for row in unresolved) == Set((:signal, :supplied)) + @test all(row -> row.origin == :missing, unresolved) + @test all(row -> occursin("add `Inputs", row.detail), unresolved) + @test_throws "Missing required composite-model/object input" run!(unresolved_scene) + + environment_scene = CompositeModel( + StabilizationEnvironmentModel(); + environment=(T=20.0, duration=3600.0), + ) + temperature_row = only( + row for row in explain_initialization(environment_scene) + if row.role == :environment_input && row.variable == :T + ) + @test temperature_row.disposition == :environment_bound +end + +@testset "public namespace boundary" begin + public_names = names(PlantSimEngine) + advanced_names = names(PlantSimEngine.Advanced) + for public_name in ( + :CompositeModel, + :CompositeModelTemplate, + :Simulation, + :RunContext, + :CallTarget, + :model_objects, + :runtime_model, + :explain_applications, + ) + @test public_name in public_names + end + for internal_name in ( + :ObjectRegistry, + :CompiledCompositeModel, + :CompiledModelApplication, + :compile_composite_model, + :refresh_bindings!, + :bindings_dirty, + ) + @test internal_name ∉ public_names + @test internal_name ∈ advanced_names + end +end + +@testset "composite model template constructor" begin + template = CompositeModelTemplate(( + ModelSpec(StabilizationSourceModel()) |> + AppliesTo(One(scale=:Leaf)), + ); species=:test_species) + root = Object(:plant; scale=:Plant) + leaf = Object(:leaf; scale=:Leaf, parent=:plant) + + model = CompositeModel( + template; + root=root, + objects=(leaf,), + environment=(duration=1.0,), + ) + + @test model isa CompositeModel + @test only(model.instances).template === template + @test only(model_objects(model; scale=:Plant)).species == :test_species + @test only(model_objects(model; scale=:Leaf)).species == :test_species + run!(model) + @test only(model_objects(model; scale=:Leaf)).status.signal == 1.0 +end + +@testset "sanctioned runtime model access" begin + model = CompositeModel(StabilizationContextModel()) + run!(model) + @test only(model_objects(model)).status.seen_revision == Advanced.model_revision(model) +end + +@testset "explicit output retention and continuation" begin + default_scene = CompositeModel(StabilizationSourceModel()) + @test isempty(explain_output_retention(default_scene)) + @test only(explain_output_retention(default_scene; outputs=:all)).reasons == + (:all_outputs,) + default_simulation = run!(default_scene; steps=2) + @test isempty(outputs(default_simulation)) + @test current_step(default_simulation) == 2 + + retained_scene = CompositeModel(StabilizationSourceModel()) + simulation = run!(retained_scene; steps=2, outputs=:all) + @test current_step(simulation) == 2 + @test last.(outputs(simulation)[ + (:stabilization_source, ObjectId(:scene), :signal) + ]) == [1.0, 2.0] + + @test continue!(simulation; steps=2) === simulation + @test current_step(simulation) == 4 + @test last.(outputs(simulation)[ + (:stabilization_source, ObjectId(:scene), :signal) + ]) == [1.0, 2.0, 3.0, 4.0] + + @test step!(simulation) === simulation + @test current_step(simulation) == 5 + @test last(outputs(simulation)[ + (:stabilization_source, ObjectId(:scene), :signal) + ]) == (5.0, 5.0) + + fresh_result = run!(retained_scene; outputs=:all) + @test current_step(fresh_result) == 1 + @test only(outputs(fresh_result)[ + (:stabilization_source, ObjectId(:scene), :signal) + ]) == (1.0, 6.0) +end + +@testset "self and subtree have distinct scope semantics" begin + model = CompositeModel( + Object(:plant; scale=:Plant), + Object(:leaf_1; scale=:Leaf, parent=:plant), + Object(:leaf_2; scale=:Leaf, parent=:plant), + ) + + @test resolve_object_ids(model, One(Self()); context=:plant) == + [ObjectId(:plant)] + @test resolve_object_ids(model, Many(Subtree()); context=:plant) == + ObjectId[ObjectId(:leaf_1), ObjectId(:leaf_2), ObjectId(:plant)] + @test resolve_object_ids(model, Many(scale=:Leaf, within=Self()); context=:plant) == + ObjectId[] + @test resolve_object_ids(model, Many(scale=:Leaf, within=Subtree()); context=:plant) == + ObjectId[ObjectId(:leaf_1), ObjectId(:leaf_2)] + @test_throws "No matching ancestor" resolve_object_ids( + model, + Many(within=Ancestor(scale=:Plant)); + context=:plant, + ) +end + +@testset "repeated applications require explicit identity" begin + model = CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec(StabilizationSourceModel()) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(StabilizationSourceModel()) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + + @test_throws "unnamed applications for process `stabilization_source`" Advanced.compile_composite_model(model) + + named_scene = CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec(StabilizationSourceModel(); name=:source_a) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(StabilizationSourceModel(); name=:source_b) |> + AppliesTo(One(scale=:Leaf)) |> + OutputRouting(; signal=:stream_only), + ), + ) + @test getproperty.(explain_applications(named_scene), :application_id) == + [:source_a, :source_b] + @test explain_schedule(named_scene) isa Vector + @test explain_bindings(named_scene) isa Vector + @test explain_calls(named_scene) isa Vector + @test explain_model_bundles(named_scene) isa Vector + @test explain_writers(named_scene) isa Vector + + ambiguous_process_scene = CompositeModel( + Object(:leaf; scale=:Leaf, status=Status(supplied=0.0)); + applications=( + ModelSpec(StabilizationSourceModel(); name=:source_a) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(StabilizationSourceModel(); name=:source_b) |> + AppliesTo(One(scale=:Leaf)) |> + OutputRouting(; signal=:stream_only), + ModelSpec(StabilizationConsumerModel(); name=:consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs(:signal => One(within=Self(), process=:stabilization_source)), + ), + ) + @test_throws "matched several source applications `[:source_a, :source_b]`" explain_bindings( + ambiguous_process_scene, + ) + + ordered_writer_scene = CompositeModel( + Object(:leaf; scale=:Leaf, status=Status(supplied=0.0)); + applications=( + ModelSpec(StabilizationSourceModel(); name=:source_a) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(StabilizationSourceModel(); name=:source_b) |> + AppliesTo(One(scale=:Leaf)) |> + Updates(:signal; after=:source_a), + ModelSpec(StabilizationConsumerModel(); name=:consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs( + PreviousTimeStep(:signal) => One(within=Self(), var=:signal), + ), + ), + ) + ordered_binding = only( + row for row in explain_bindings(ordered_writer_scene) + if row.application_id == :consumer && row.input == :signal + ) + @test ordered_binding.source_application_ids == [:source_b] +end + +@testset "invalid reparenting is atomic" begin + model = CompositeModel( + Object(:plant; scale=:Plant), + Object(:leaf; scale=:Leaf, parent=:plant), + ) + + @test_throws "below its descendant" reparent_object!(model, :plant, :leaf) + @test only(resolve_object_ids(model, One(Relation(:parent)); context=:leaf)) == + ObjectId(:plant) + @test resolve_object_ids(model, Many(Relation(:children)); context=:plant) == + [ObjectId(:leaf)] + + @test_throws "to itself" reparent_object!(model, :plant, :plant) + @test isnothing(only(model_objects(model; scale=:Plant)).parent) +end + +@testset "instance roots are immutable lifecycle anchors" begin + template = CompositeModelTemplate(( + ModelSpec(StabilizationSourceModel(); name=:source) |> + AppliesTo(Many(scale=:Leaf)), + )) + instance = ObjectInstance( + :plant_instance, + template; + root=Object(:plant; scale=:Plant, parent=:branch), + objects=(Object(:leaf; scale=:Leaf, parent=:plant),), + ) + model = CompositeModel( + Object(:world; scale=:Scene), + Object(:outside; scale=:Branch), + Object(:branch; scale=:Branch, parent=:world), + instance, + ) + + @test_throws "immutable ObjectInstance root" remove_object!(model, :plant) + @test_throws "immutable ObjectInstance root" remove_object!(model, :branch) + @test object_ids(model) == ObjectId.([:branch, :leaf, :outside, :plant, :world]) + @test only(model_objects(model; name=:plant_instance)).id == ObjectId(:plant) + + @test_throws "immutable ObjectInstance root" reparent_object!(model, :plant, :outside) + @test_throws "immutable ObjectInstance root" reparent_object!(model, :branch, :outside) + @test only(resolve_object_ids(model, One(Relation(:parent)); context=:plant)) == + ObjectId(:branch) + @test only(resolve_object_ids(model, One(Relation(:parent)); context=:branch)) == + ObjectId(:world) + + reparent_object!(model, :leaf, :outside) + @test only(resolve_object_ids(model, One(Relation(:parent)); context=:leaf)) == + ObjectId(:outside) + reparent_object!(model, :leaf, :plant) + removed = remove_object!(model, :leaf) + @test removed.id == ObjectId(:leaf) + @test isempty(resolve_object_ids(model, Many(scale=:Leaf))) +end + +@testset "continuation refreshes lifecycle targets and preserves history" begin + model = CompositeModel( + Object(:leaf_1; scale=:Leaf); + applications=( + ModelSpec(StabilizationSourceModel(); name=:source) |> + AppliesTo(Many(scale=:Leaf)), + ), + ) + request = OutputRequest( + Many(scale=:Leaf), + :signal; + name=:leaf_signal, + application=:source, + ) + simulation = run!(model; outputs=request) + + register_object!(model, Object(:leaf_2; scale=:Leaf)) + continue!(simulation) + remove_object!(model, :leaf_1) + continue!(simulation) + + rows = collect_outputs(simulation, :leaf_signal; sink=nothing) + leaf_1_rows = filter(row -> row.object_id == :leaf_1, rows) + leaf_2_rows = filter(row -> row.object_id == :leaf_2, rows) + @test getproperty.(leaf_1_rows, :timestep) == [1, 2] + @test getproperty.(leaf_1_rows, :value) == [1.0, 2.0] + @test getproperty.(leaf_2_rows, :timestep) == [2, 3] + @test getproperty.(leaf_2_rows, :value) == [1.0, 2.0] + @test all(row -> row.scale == :Leaf, rows) +end + +@testset "output retention allocation boundary" begin + model = CompositeModel( + (Object(Symbol(:leaf_, i); scale=:Leaf) for i in 1:100)...; + applications=( + ModelSpec(StabilizationSourceModel(); name=:source) |> + AppliesTo(Many(scale=:Leaf)), + ), + ) + + run!(model; steps=2, outputs=:none) + run!(model; steps=2, outputs=:all) + none_allocations = @allocated run!(model; steps=10, outputs=:none) + all_allocations = @allocated run!(model; steps=10, outputs=:all) + @test none_allocations < all_allocations +end diff --git a/test/test-model-binding-inference.jl b/test/test-model-binding-inference.jl new file mode 100644 index 000000000..66f3284e1 --- /dev/null +++ b/test/test-model-binding-inference.jl @@ -0,0 +1,102 @@ +using PlantSimEngine +using Test + +PlantSimEngine.@process "lineage_source" verbose = false +PlantSimEngine.@process "lineage_consumer" verbose = false + +struct LineageSourceModel{T} <: AbstractLineage_SourceModel + value::T +end +struct LineageConsumerModel <: AbstractLineage_ConsumerModel end +PlantSimEngine.inputs_(::LineageSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::LineageSourceModel) = (signal=0.0,) +function PlantSimEngine.run!(model::LineageSourceModel, models, status, meteo, constants, extra) + status.signal = model.value +end +PlantSimEngine.inputs_(::LineageConsumerModel) = (signal=0.0,) +PlantSimEngine.outputs_(::LineageConsumerModel) = (observed=0.0,) +function PlantSimEngine.run!(::LineageConsumerModel, models, status, meteo, constants, extra) + status.observed = status.signal +end + +@testset "producer lineage, scope, and ambiguity" begin + same_object = CompositeModel( + Object(:scene; scale=:Scene), + Object(:plant; scale=:Plant, parent=:scene), + Object(:soil; scale=:Soil, parent=:scene), + Object(:leaf; scale=:Leaf, parent=:plant); + applications=( + ModelSpec(LineageSourceModel(1.0); name=:plant_source) |> + AppliesTo(One(scale=:Plant)), + ModelSpec(LineageSourceModel(2.0); name=:soil_source) |> + AppliesTo(One(scale=:Soil)), + ModelSpec(LineageSourceModel(3.0); name=:leaf_source) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(LineageConsumerModel(); name=:leaf_consumer) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + binding = only( + row for row in explain_bindings(Advanced.refresh_bindings!(same_object)) + if row.application_id == :leaf_consumer + ) + @test binding.origin == :inferred_same_object + @test binding.source_ids == [:leaf] + @test binding.source_application_ids == [:leaf_source] + run!(same_object) + @test only(model_objects(same_object; scale=:Leaf)).status.observed == 3.0 + + ambiguous = CompositeModel( + Object(:scene; scale=:Scene), + Object(:plant; scale=:Plant, parent=:scene), + Object(:soil; scale=:Soil, parent=:scene), + Object(:leaf; scale=:Leaf, parent=:plant); + applications=( + ModelSpec(LineageSourceModel(1.0); name=:plant_source) |> + AppliesTo(One(scale=:Plant)), + ModelSpec(LineageSourceModel(2.0); name=:soil_source) |> + AppliesTo(One(scale=:Soil)), + ModelSpec(LineageConsumerModel(); name=:leaf_consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs(:signal => One( + within=SceneScope(), + process=:lineage_source, + var=:signal, + )), + ), + ) + error = try + Advanced.refresh_bindings!(ambiguous) + nothing + catch exception + sprint(showerror, exception) + end + @test occursin("Expected exactly one object", error) + @test occursin("plant", error) + @test occursin("soil", error) + + explicit = CompositeModel( + Object(:scene; scale=:Scene), + Object(:plant; scale=:Plant, parent=:scene), + Object(:soil; scale=:Soil, parent=:scene), + Object(:leaf; scale=:Leaf, parent=:plant); + applications=( + ModelSpec(LineageSourceModel(1.0); name=:plant_source) |> + AppliesTo(One(scale=:Plant)), + ModelSpec(LineageSourceModel(2.0); name=:soil_source) |> + AppliesTo(One(scale=:Soil)), + ModelSpec(LineageConsumerModel(); name=:leaf_consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs(:signal => One( + scale=:Plant, + within=Ancestor(scale=:Plant), + application=:plant_source, + var=:signal, + )), + ), + ) + explicit_binding = only(explain_bindings(Advanced.refresh_bindings!(explicit))) + @test explicit_binding.source_ids == [:plant] + run!(explicit) + @test only(model_objects(explicit; scale=:Leaf)).status.observed == 1.0 +end diff --git a/test/test-model-configuration-errors.jl b/test/test-model-configuration-errors.jl new file mode 100644 index 000000000..8706b823c --- /dev/null +++ b/test/test-model-configuration-errors.jl @@ -0,0 +1,78 @@ +using Dates +using PlantSimEngine +using Test + +PlantSimEngine.@process "invalid_hint_probe" verbose = false +PlantSimEngine.@process "configuration_probe" verbose = false +PlantSimEngine.@process "configuration_consumer" verbose = false +struct InvalidMeteoHintProbeModel <: AbstractInvalid_Hint_ProbeModel end +struct InvalidTimestepHintProbeModel <: AbstractInvalid_Hint_ProbeModel end +struct ConfigurationProbeModel <: AbstractConfiguration_ProbeModel end +struct ConfigurationConsumerModel <: AbstractConfiguration_ConsumerModel end +PlantSimEngine.inputs_(::Union{InvalidMeteoHintProbeModel,InvalidTimestepHintProbeModel}) = NamedTuple() +PlantSimEngine.outputs_(::Union{InvalidMeteoHintProbeModel,InvalidTimestepHintProbeModel}) = (value=0.0,) +PlantSimEngine.meteo_hint(::Type{<:InvalidMeteoHintProbeModel}) = 42 +PlantSimEngine.timestep_hint(::Type{<:InvalidTimestepHintProbeModel}) = "hourly" +PlantSimEngine.inputs_(::ConfigurationProbeModel) = NamedTuple() +PlantSimEngine.outputs_(::ConfigurationProbeModel) = (value=0.0,) +PlantSimEngine.meteo_inputs_(::ConfigurationProbeModel) = (T=0.0,) +PlantSimEngine.inputs_(::ConfigurationConsumerModel) = (value=0.0,) +PlantSimEngine.outputs_(::ConfigurationConsumerModel) = (observed=0.0,) + +function invalid_hint_scene(model) + return CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec(model; name=:invalid_hint) |> AppliesTo(One(scale=:Leaf)), + ), + environment=(duration=Hour(1),), + ) +end + +@testset "current configuration errors" begin + @test_throws "Unsupported reducer value" Aggregate(42) + monthly_scene = CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec(InvalidTimestepHintProbeModel(); name=:monthly) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Month(1)), + ), + environment=(duration=Hour(1),), + ) + @test_throws "Unsupported non-fixed period" Advanced.refresh_bindings!(monthly_scene) + @test_throws "Invalid meteo_hint" Advanced.refresh_bindings!( + invalid_hint_scene(InvalidMeteoHintProbeModel()) + ) + @test_throws "Invalid timestep_hint" Advanced.refresh_bindings!( + invalid_hint_scene(InvalidTimestepHintProbeModel()) + ) + invalid_environment = CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec(ConfigurationProbeModel(); name=:probe) |> + AppliesTo(One(scale=:Leaf)) |> + Environment(provider=:global, sources=42), + ), + environment=(T=20.0, duration=Hour(1)), + ) + @test_throws Exception Advanced.refresh_bindings!(invalid_environment) + + invalid_policy = CompositeModel( + Object(:source; scale=:Leaf, name=:source), + Object(:consumer; scale=:Leaf, name=:consumer); + applications=( + ModelSpec(ConfigurationProbeModel(); name=:source) |> + AppliesTo(One(name=:source)), + ModelSpec(ConfigurationConsumerModel(); name=:consumer) |> + AppliesTo(One(name=:consumer)) |> + Inputs(:value => One( + name=:source, + within=SceneScope(), + policy=:latest, + )), + ), + environment=(T=20.0, duration=Hour(1)), + ) + @test_throws "Unsupported model input policy" Advanced.refresh_bindings!(invalid_policy) +end diff --git a/test/test-model-contract.jl b/test/test-model-contract.jl new file mode 100644 index 000000000..3ea1fbde3 --- /dev/null +++ b/test/test-model-contract.jl @@ -0,0 +1,42 @@ +using Dates +using PlantSimEngine +using Test + +PlantSimEngine.@process "contract_defaults" verbose = false + +struct ContractDefaultsModel <: AbstractContract_DefaultsModel end +struct ContractExplicitModel <: AbstractContract_DefaultsModel end + +PlantSimEngine.inputs_(::ContractDefaultsModel) = NamedTuple() +PlantSimEngine.outputs_(::ContractDefaultsModel) = (value=0,) +PlantSimEngine.inputs_(::ContractExplicitModel) = NamedTuple() +PlantSimEngine.outputs_(::ContractExplicitModel) = (value=0,) +PlantSimEngine.timespec(::Type{<:ContractExplicitModel}) = ClockSpec(2, 1) +PlantSimEngine.output_policy(::Type{<:ContractExplicitModel}) = (value=Aggregate(),) +PlantSimEngine.timestep_hint(::Type{<:ContractExplicitModel}) = (preferred=Dates.Hour(2),) +PlantSimEngine.meteo_hint(::Type{<:ContractExplicitModel}) = (window=Dates.Hour(2),) +PlantSimEngine.meteo_inputs_(::ContractExplicitModel) = (T=0,) +PlantSimEngine.meteo_outputs_(::ContractExplicitModel) = (T=0,) + +@testset "direct model trait defaults" begin + model = ContractDefaultsModel() + @test process(model) == :contract_defaults + @test application_name(ModelSpec(model)) === nothing + default_scene = CompositeModel(model) + @test only(explain_applications(Advanced.refresh_bindings!(default_scene))).application_id == + :contract_defaults + @test timespec(model) == ClockSpec(1.0, 0.0) + @test output_policy(model) == NamedTuple() + @test timestep_hint(model) === nothing + @test meteo_hint(model) === nothing + @test meteo_inputs_(model) == NamedTuple() + @test meteo_outputs_(model) == NamedTuple() + + explicit = ContractExplicitModel() + @test timespec(explicit) == ClockSpec(2, 1) + @test output_policy(explicit).value isa Aggregate + @test timestep_hint(explicit) == (preferred=Dates.Hour(2),) + @test meteo_hint(explicit) == (window=Dates.Hour(2),) + @test meteo_inputs_(explicit) == (T=0,) + @test meteo_outputs_(explicit) == (T=0,) +end diff --git a/test/test-model-graph-editor-extension.jl b/test/test-model-graph-editor-extension.jl new file mode 100644 index 000000000..2acbd429b --- /dev/null +++ b/test/test-model-graph-editor-extension.jl @@ -0,0 +1,361 @@ +abstract type AbstractEditorSourceModel <: PlantSimEngine.AbstractModel end +abstract type AbstractEditorConsumerModel <: PlantSimEngine.AbstractModel end +PlantSimEngine.process_(::Type{AbstractEditorSourceModel}) = :editor_source +PlantSimEngine.process_(::Type{AbstractEditorConsumerModel}) = :editor_consumer + +struct EditorSourceModel <: AbstractEditorSourceModel end +PlantSimEngine.inputs_(::EditorSourceModel) = (driver=-Inf,) +PlantSimEngine.outputs_(::EditorSourceModel) = (signal=-Inf,) + +struct EditorConsumerModel <: AbstractEditorConsumerModel end +PlantSimEngine.inputs_(::EditorConsumerModel) = (signal=-Inf,) +PlantSimEngine.outputs_(::EditorConsumerModel) = (result=-Inf,) + +@testset "session lifecycle and edits" begin + model = CompositeModel( + Object(:leaf; name=:leaf, scale=:Leaf, status=Status(driver=1.0)); + applications=( + ModelSpec(EditorSourceModel(); name=:source) |> + AppliesTo(One(name=:leaf)), + ), + ) + session = edit_graph(model; port=0, open_browser=false, autosave=false) + try + @test current_model(session) !== model + @test occursin("Open in browser:", sprint(show, MIME"text/plain"(), session)) + @test occursin("Quit session: close(session)", sprint(show, MIME"text/plain"(), session)) + + health = HTTP.get("http://$(session.host):$(session.port)/health") + @test health.status == 200 + @test JSON.parse(String(health.body))["ok"] + + state = HTTP.get("http://$(session.host):$(session.port)/state?token=$(session.token)") + payload = JSON.parse(String(state.body)) + @test payload["ok"] + @test payload["graph"]["metadata"]["applicationCount"] == 1 + @test occursin("model = CompositeModel", payload["modelCode"]) + + static_view = HTTP.get("http://$(session.host):$(session.port)/static?token=$(session.token)") + @test static_view.status == 200 + @test occursin("pse-model-graph-data", String(static_view.body)) + + consumer_spec = ModelSpec(EditorConsumerModel(); name=:consumer) |> + AppliesTo(One(name=:leaf)) + apply_edit!(session, AddModelApplication(consumer_spec)) + @test length(current_model(session).applications) == 2 + @test !isempty(session.history) + + undo!(session) + @test length(current_model(session).applications) == 1 + redo!(session) + @test length(current_model(session).applications) == 2 + finally + close(session) + end +end + +@testset "empty session and automatic save" begin + output_path = joinpath(mktempdir(), "model.generated.jl") + session = edit_graph( + ; + port=0, + open_browser=false, + autosave=true, + save_path=output_path, + ) + try + @test isempty(current_model(session).applications) + @test isfile(output_path) + before = read(output_path, String) + @test occursin("model = CompositeModel", before) + + apply_edit!(session, AddModelObject(Object(:plant; name=:plant, scale=:Plant))) + after = read(output_path, String) + @test after != before + @test occursin("Object(:plant", after) + @test !isnothing(session.autosave_path) + @test isfile(session.autosave_path) + @test output_path in session.recent_paths + + snapshot_path = joinpath(mktempdir(), "saved-model.jl") + write(snapshot_path, Base.get_extension(PlantSimEngine, :PlantSimEngineGraphEditorExt)._model_to_julia(current_model(session))) + apply_edit!(session, AddModelObject(Object(:soil; name=:soil, scale=:Soil))) + @test length(model_objects(current_model(session))) == 2 + response = Base.get_extension(PlantSimEngine, :PlantSimEngineGraphEditorExt)._handle_command!(session, Dict( + "action" => "open_model_code", + "path" => snapshot_path, + )) + response["ok"] || error(join(response["diagnostics"], "\n")) + @test response["ok"] + @test length(model_objects(current_model(session))) == 1 + @test session.save_path == snapshot_path + @test first(session.recent_paths) == snapshot_path + + reopened = edit_graph(; port=0, open_browser=false, autosave=false) + try + @test snapshot_path in reopened.recent_paths + finally + close(reopened) + end + recovered = edit_graph( + ; + port=0, + open_browser=false, + autosave=false, + recover_path=snapshot_path, + ) + try + @test length(model_objects(current_model(recovered))) == 1 + finally + close(recovered) + end + finally + close(session) + end +end + + +@testset "JSON editor commands round-trip through Julia" begin + editor_extension = Base.get_extension(PlantSimEngine, :PlantSimEngineGraphEditorExt) + @test !isnothing(editor_extension) + session = edit_graph(; port=0, open_browser=false, autosave=false) + try + object_response = editor_extension._handle_command!(session, Dict( + "action" => "edit", + "kind" => "add_object", + "objectId" => "leaf", + "configuration" => Dict( + "scale" => "Leaf", + "kind" => "organ", + "species" => nothing, + "name" => "leaf", + "parent" => nothing, + ), + )) + @test object_response["ok"] + @test length(model_objects(current_model(session))) == 1 + + target_history_length = length(session.history) + target_preview = editor_extension._handle_command!(session, Dict( + "action" => "preview_application_targets", + "selector" => Dict( + "multiplicity" => "many", + "criteria" => Dict( + "selectors" => Any[], + "within" => Dict("type" => "SceneScope"), + "scale" => "Leaf", + ), + ), + )) + @test target_preview["ok"] + @test target_preview["targetPreview"]["count"] == 1 + @test target_preview["targetPreview"]["objectIds"] == ["leaf"] + @test length(session.history) == target_history_length + + source_response = editor_extension._handle_command!(session, Dict( + "action" => "edit", + "kind" => "add_application", + "name" => "source", + "modelType" => string(EditorSourceModel), + "parameters" => Dict(), + "selector" => Dict( + "multiplicity" => "one", + "criteria" => Dict( + "selectors" => [Dict("type" => "Scale", "scale" => "Leaf")], + ), + ), + "timestep" => Dict("mode" => "default"), + )) + @test source_response["ok"] + + environment_response = editor_extension._handle_command!(session, Dict( + "action" => "edit", + "kind" => "set_environment_provider", + "applicationId" => "source", + "provider" => "model", + )) + @test environment_response["ok"] + source_application = only( + PlantSimEngine.as_model_spec(spec) for spec in current_model(session).applications + if application_name(PlantSimEngine.as_model_spec(spec)) == :source + ) + @test environment_config(source_application).provider == :model + + routing_response = editor_extension._handle_command!(session, Dict( + "action" => "edit", + "kind" => "set_output_routing", + "applicationId" => "source", + "output" => "signal", + "route" => "stream_only", + )) + @test routing_response["ok"] + @test only( + application for application in routing_response["graph"]["applications"] + if application["applicationId"] == "source" + )["outputRouting"]["signal"] == "stream_only" + + consumer_response = editor_extension._handle_command!(session, Dict( + "action" => "edit", + "kind" => "add_application", + "name" => "consumer", + "modelType" => string(EditorConsumerModel), + "parameters" => Dict(), + "selector" => Dict( + "multiplicity" => "one", + "criteria" => Dict("selectors" => Any[], "name" => "leaf"), + ), + "timestep" => Dict("mode" => "clock", "dt" => "2.0", "phase" => "0.0"), + )) + @test consumer_response["ok"] + + call_selector = Dict( + "multiplicity" => "one", + "criteria" => Dict( + "selectors" => Any[], + "within" => Dict("type" => "Self"), + "application" => "source", + ), + ) + call_response = editor_extension._handle_command!(session, Dict( + "action" => "edit", + "kind" => "set_call_binding", + "applicationId" => "consumer", + "call" => "source_call", + "selector" => call_selector, + )) + @test call_response["ok"] + @test call_response["graph"]["metadata"]["callCount"] == 1 + remove_call_response = editor_extension._handle_command!(session, Dict( + "action" => "edit", + "kind" => "remove_call_binding", + "applicationId" => "consumer", + "call" => "source_call", + )) + @test remove_call_response["ok"] + @test remove_call_response["graph"]["metadata"]["callCount"] == 0 + + binding_command = Dict( + "applicationId" => "consumer", + "input" => "signal", + "selector" => Dict( + "multiplicity" => "one", + "criteria" => Dict( + "selectors" => Any[], + "within" => Dict("type" => "Self"), + "application" => "source", + "var" => "signal", + "policy" => Dict("type" => "HoldLast"), + ), + ), + ) + history_length = length(session.history) + preview_response = editor_extension._handle_command!(session, merge( + binding_command, + Dict("action" => "preview_input_binding"), + )) + @test preview_response["ok"] + @test preview_response["selectorPreview"]["bindingCount"] == 1 + @test preview_response["selectorPreview"]["consumerObjectIds"] == ["leaf"] + @test preview_response["selectorPreview"]["sourceObjectIds"] == ["leaf"] + @test preview_response["selectorPreview"]["sourceApplicationIds"] == ["source"] + @test length(session.history) == history_length + consumer_before = only( + PlantSimEngine.as_model_spec(spec) for spec in current_model(session).applications + if application_name(PlantSimEngine.as_model_spec(spec)) == :consumer + ) + @test isempty(consumer_before.inputs) + + binding_response = editor_extension._handle_command!(session, merge( + binding_command, + Dict("action" => "edit", "kind" => "set_input_binding"), + )) + @test binding_response["ok"] + @test binding_response["graph"]["metadata"]["bindingCount"] == 1 + @test binding_response["graph"]["metadata"]["applicationCount"] == 2 + + status_response = editor_extension._handle_command!(session, Dict( + "action" => "edit", + "kind" => "set_object_statuses", + "objectIds" => ["leaf"], + "variable" => "driver", + "value" => Dict("type" => "float", "value" => "3.5"), + )) + @test status_response["ok"] + @test only(model_objects(current_model(session))).status.driver == 3.5 + + undo_response = editor_extension._handle_command!(session, Dict("action" => "undo")) + @test undo_response["ok"] + restored_status = only(model_objects(current_model(session))).status + @test isnothing(restored_status) || !(:driver in propertynames(restored_status)) + finally + close(session) + end +end + + +@testset "generated Julia code reconstructs templates and overrides" begin + editor_extension = Base.get_extension(PlantSimEngine, :PlantSimEngineGraphEditorExt) + template = CompositeModelTemplate( + ( + ModelSpec(EditorSourceModel(); name=:source) |> + AppliesTo(Many(scale=:Leaf)) |> + Environment((provider=:model,)) |> + OutputRouting((signal=:stream_only,)), + ); + kind=:plant, + species=:test_species, + ) + original = CompositeModel( + ObjectInstance( + :plant_a, + template; + root=Object(:plant_a; scale=:Plant), + objects=(Object(:leaf_a; scale=:Leaf, parent=:plant_a, status=Status(driver=1.0)),), + ), + ObjectInstance( + :plant_b, + template; + root=Object(:plant_b; scale=:Plant), + objects=(Object(:leaf_b; scale=:Leaf, parent=:plant_b, status=Status(driver=1.0)),), + overrides=(source=EditorSourceModel(),), + object_overrides=(Override(object=:leaf_b, application=:source, model=EditorSourceModel()),), + ), + ) + code = editor_extension._model_to_julia(original) + @test occursin("defined in Main", code) + @test occursin("template_1 = CompositeModelTemplate", code) + @test occursin("instances = (", code) + @test occursin("Override(", code) + @test occursin("Environment((provider = :model,))", code) + @test occursin("OutputRouting((signal = :stream_only,))", code) + @test !occursin("ObjectModelOverrides", code) + + restored = Base.include_string(Main, code, "generated_model_editor_test.jl") + original_view = model_graph_view(original) + restored_view = model_graph_view(restored) + @test restored_view.metadata["objectCount"] == original_view.metadata["objectCount"] + @test restored_view.metadata["instanceCount"] == original_view.metadata["instanceCount"] + @test Set(application["applicationId"] for application in restored_view.applications) == + Set(application["applicationId"] for application in original_view.applications) + restored_source = PlantSimEngine.as_model_spec(first(first(restored.instances).template.applications)) + @test environment_config(restored_source).config == (provider=:model,) + @test output_routing(restored_source) == (signal=:stream_only,) + + local_model = CompositeModel(Object( + :local_leaf; + name=:local_leaf, + scale=:Leaf, + status=Status(driver=1.0), + applications=( + ModelSpec(EditorSourceModel(); name=:local_source) |> + AppliesTo(One(name=:local_leaf)), + ), + )) + local_code = editor_extension._model_to_julia(local_model) + @test occursin("applications=(ModelSpec", local_code) + restored_local = Base.include_string(Main, local_code, "generated_local_model_editor_test.jl") + restored_local_application = only(only(model_objects(restored_local)).applications) + @test PlantSimEngine.application_name( + PlantSimEngine.as_model_spec(restored_local_application), + ) == :local_source +end diff --git a/test/test-model-graph-view.jl b/test/test-model-graph-view.jl new file mode 100644 index 000000000..391ff097b --- /dev/null +++ b/test/test-model-graph-view.jl @@ -0,0 +1,584 @@ +abstract type AbstractModelGraphSourceModel <: PlantSimEngine.AbstractModel end +abstract type AbstractModelGraphConsumerModel <: PlantSimEngine.AbstractModel end +abstract type AbstractModelGraphCycleAModel <: PlantSimEngine.AbstractModel end +abstract type AbstractModelGraphCycleBModel <: PlantSimEngine.AbstractModel end +abstract type AbstractModelGraphEnvironmentModel <: PlantSimEngine.AbstractModel end + +PlantSimEngine.process_(::Type{AbstractModelGraphSourceModel}) = :model_graph_source +PlantSimEngine.process_(::Type{AbstractModelGraphConsumerModel}) = :model_graph_consumer +PlantSimEngine.process_(::Type{AbstractModelGraphCycleAModel}) = :model_graph_cycle_a +PlantSimEngine.process_(::Type{AbstractModelGraphCycleBModel}) = :model_graph_cycle_b +PlantSimEngine.process_(::Type{AbstractModelGraphEnvironmentModel}) = :model_graph_environment + +struct ModelGraphSourceModel{T} <: AbstractModelGraphSourceModel + coefficient::T +end + +ModelGraphSourceModel() = ModelGraphSourceModel(2.0) +PlantSimEngine.inputs_(::ModelGraphSourceModel) = (driver=-Inf,) +PlantSimEngine.outputs_(::ModelGraphSourceModel) = (signal=-Inf,) + +struct ModelGraphConsumerModel <: AbstractModelGraphConsumerModel end +PlantSimEngine.inputs_(::ModelGraphConsumerModel) = (signal=-Inf,) +PlantSimEngine.outputs_(::ModelGraphConsumerModel) = (result=-Inf,) + +struct ModelGraphCycleAModel <: AbstractModelGraphCycleAModel end +PlantSimEngine.inputs_(::ModelGraphCycleAModel) = (y=-Inf,) +PlantSimEngine.outputs_(::ModelGraphCycleAModel) = (x=-Inf,) + +struct ModelGraphCycleBModel <: AbstractModelGraphCycleBModel end +PlantSimEngine.inputs_(::ModelGraphCycleBModel) = (x=-Inf,) +PlantSimEngine.outputs_(::ModelGraphCycleBModel) = (y=-Inf,) + +struct ModelGraphEnvironmentModel <: AbstractModelGraphEnvironmentModel end +PlantSimEngine.inputs_(::ModelGraphEnvironmentModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelGraphEnvironmentModel) = (result=-Inf,) +PlantSimEngine.meteo_inputs_(::ModelGraphEnvironmentModel) = (T=-Inf,) +PlantSimEngine.meteo_outputs_(::ModelGraphEnvironmentModel) = (leaf_temperature=-Inf,) + +@testset "CompositeModel graph discovery" begin + @test AbstractModelGraphSourceModel in available_processes() + @test ModelGraphSourceModel in available_models(:model_graph_source) + + descriptor = model_descriptor(ModelGraphSourceModel) + @test descriptor["process"] == "model_graph_source" + @test descriptor["inputs"]["driver"] == "-Inf" + @test descriptor["outputs"]["signal"] == "-Inf" + @test descriptor["constructor"]["hasZeroArgConstructor"] + @test descriptor["constructor"]["fields"][1]["name"] == "coefficient" +end + +@testset "CompositeModel graph application and resolved views" begin + model = CompositeModel( + Object(:leaf; name=:leaf, scale=:Leaf, kind=:organ, status=Status(driver=1.0)); + applications=( + ModelSpec(ModelGraphSourceModel(); name=:source) |> + AppliesTo(One(name=:leaf)), + ModelSpec(ModelGraphConsumerModel(); name=:consumer) |> + AppliesTo(One(name=:leaf)), + ), + ) + + report = compile_model_report(model) + @test isempty(report.diagnostics) + @test !isnothing(report.compiled) + @test report.application_order == [:source, :consumer] + + view = model_graph_view(model) + @test view isa ModelGraphView + @test view.metadata["objectCount"] == 1 + @test view.metadata["applicationCount"] == 2 + @test view.metadata["executionCount"] == 2 + @test !view.metadata["cyclic"] + @test any(application -> application["applicationId"] == "source", view.applications) + @test any( + edge -> edge["kind"] in ("value_binding", "inferred_same_object") && + edge["sourceVariable"] == "signal" && + edge["targetVariable"] == "signal", + view.edges, + ) + + resolved = model_graph_view(model; level=:resolved) + @test length(resolved.executions) == 2 + @test any(edge -> edge["kind"] in ("value_binding", "inferred_same_object"), resolved.edges) + + json = model_graph_view_json(view) + @test occursin("\"applications\"", json) + @test occursin("ModelGraphSourceModel", json) + + path = write_model_graph_view( + joinpath(mktempdir(), "model-graph.html"), + view; + renderer=:standalone, + ) + html = read(path, String) + @test occursin("PlantSimEngine CompositeModel Graph", html) + @test occursin("pse-model-graph-data", html) + @test occursin("Applications", html) +end + +@testset "CompositeModel graph instances and overrides" begin + template = CompositeModelTemplate( + ( + ModelSpec(ModelGraphSourceModel(); name=:source) |> + AppliesTo(Many(scale=:Leaf)), + ); + kind=:plant, + species=:test_species, + ) + plant_a = ObjectInstance( + :plant_a, + template; + root=Object(:plant_a; scale=:Plant, kind=:plant), + objects=(Object(:leaf_a; scale=:Leaf, kind=:organ, parent=:plant_a, status=Status(driver=1.0)),), + ) + plant_b = ObjectInstance( + :plant_b, + template; + root=Object(:plant_b; scale=:Plant, kind=:plant), + objects=(Object(:leaf_b; scale=:Leaf, kind=:organ, parent=:plant_b, status=Status(driver=1.0)),), + overrides=(source=ModelGraphSourceModel(3.0),), + ) + model = CompositeModel(plant_a, plant_b) + view = model_graph_view(model) + + @test view.metadata["instanceCount"] == 2 + @test Set(instance["name"] for instance in view.instances) == Set(["plant_a", "plant_b"]) + @test Set(application["applicationId"] for application in view.applications) == + Set(["plant_a__source", "plant_b__source"]) + plant_b_application = only( + application for application in view.applications + if application["applicationId"] == "plant_b__source" + ) + @test plant_b_application["modelParameters"]["coefficient"]["value"] == 3.0 + @test plant_b_application["targetIds"] == ["leaf_b"] +end + +@testset "CompositeModel graph invalid and cyclic reports" begin + invalid_scene = CompositeModel( + Object(:leaf; name=:leaf, scale=:Leaf); + applications=(ModelSpec(ModelGraphSourceModel(); name=:source),), + ) + invalid_report = compile_model_report(invalid_scene) + @test any(diagnostic -> diagnostic.phase == :applications, invalid_report.diagnostics) + @test_throws Exception compile_model_report(invalid_scene; strict=true) + + cyclic_scene = CompositeModel( + Object(:leaf; name=:leaf, scale=:Leaf, status=Status()); + applications=( + ModelSpec(ModelGraphCycleAModel(); name=:cycle_a) |> + AppliesTo(One(name=:leaf)), + ModelSpec(ModelGraphCycleBModel(); name=:cycle_b) |> + AppliesTo(One(name=:leaf)), + ), + ) + report = compile_model_report(cyclic_scene) + @test report.cycles == [[:cycle_a, :cycle_b]] + @test any(diagnostic -> diagnostic.code == :application_cycle, report.diagnostics) + @test isnothing(report.compiled) + + view = model_graph_view(cyclic_scene) + @test view.metadata["cyclic"] + @test length(view.cycles) == 1 + @test Set(view.cycles[1]["applicationIds"]) == Set(["cycle_a", "cycle_b"]) + @test length(view.cycles[1]["breakCandidates"]) == 2 + @test any(edge -> edge["cycle"] == true, view.edges) + @test_throws Exception compile_composite_model(cyclic_scene) + + broken_scene = CompositeModel( + Object(:leaf; name=:leaf, scale=:Leaf, status=Status(y=0.0)); + applications=( + ModelSpec(ModelGraphCycleAModel(); name=:cycle_a) |> + AppliesTo(One(name=:leaf)) |> + Inputs(PreviousTimeStep(:y) => One(within=Self(), var=:y)), + ModelSpec(ModelGraphCycleBModel(); name=:cycle_b) |> + AppliesTo(One(name=:leaf)), + ), + ) + broken_view = model_graph_view(broken_scene) + @test !broken_view.metadata["cyclic"] + @test any(edge -> edge["kind"] == "previous_timestep", broken_view.edges) +end + +@testset "CompositeModel graph initialization comes from Julia" begin + model = CompositeModel( + Object(:leaf; name=:leaf, scale=:Leaf, status=Status()); + applications=( + ModelSpec(ModelGraphConsumerModel(); name=:consumer) |> + AppliesTo(One(name=:leaf)), + ), + ) + view = model_graph_view(model) + unresolved = only( + row for row in view.initialization + if row["applicationId"] == "consumer" && row["variable"] == "signal" + ) + @test unresolved["disposition"] == "unresolved" + @test view.metadata["unresolvedInitializationCount"] == 1 +end + +@testset "CompositeModel graph edits are transactional" begin + model = CompositeModel(Object(:leaf; name=:leaf, scale=:Leaf, status=Status(driver=1.0))) + source_spec = ModelSpec(ModelGraphSourceModel(); name=:source) |> + AppliesTo(One(name=:leaf)) + with_source = apply_model_graph_edit(model, AddModelApplication(source_spec)) + @test isempty(model.applications) + @test length(with_source.applications) == 1 + @test_throws "already exists" apply_model_graph_edit( + with_source, + AddModelApplication(source_spec), + ) + @test length(with_source.applications) == 1 + + changed_model = apply_model_graph_edit( + with_source, + ReplaceModelApplicationModel(:source, ModelGraphSourceModel(4.0)), + ) + changed_view = model_graph_view(changed_model) + changed_application = only(changed_view.applications) + @test changed_application["modelParameters"]["coefficient"]["value"] == 4.0 + + changed_status = apply_model_graph_edit( + changed_model, + SetModelObjectStatus(:leaf, :driver, 3.0), + ) + @test only(model_objects(changed_status)).status.driver == 3.0 + @test only(model_objects(changed_model)).status.driver == 1.0 + + removed = apply_model_graph_edit( + changed_status, + RemoveModelApplication(:source), + ) + @test isempty(removed.applications) +end + +@testset "CompositeModel graph edit breaks inferred cycles" begin + model = CompositeModel( + Object(:leaf; name=:leaf, scale=:Leaf, status=Status(y=0.0)); + applications=( + ModelSpec(ModelGraphCycleAModel(); name=:cycle_a) |> + AppliesTo(One(name=:leaf)), + ModelSpec(ModelGraphCycleBModel(); name=:cycle_b) |> + AppliesTo(One(name=:leaf)), + ), + ) + @test model_graph_view(model).metadata["cyclic"] + + broken = apply_model_graph_edit( + model, + MarkModelPreviousTimeStep(:cycle_a, :y), + ) + broken_view = model_graph_view(broken) + @test !broken_view.metadata["cyclic"] + @test any(edge -> edge["kind"] == "previous_timestep", broken_view.edges) + + restored = apply_model_graph_edit( + broken, + UnmarkModelPreviousTimeStep(:cycle_a, :y), + ) + @test model_graph_view(restored).metadata["cyclic"] + + initialized_scene = CompositeModel( + Object(:leaf; name=:leaf, scale=:Leaf, status=Status()); + applications=( + ModelSpec(ModelGraphCycleAModel(); name=:cycle_a) |> + AppliesTo(One(name=:leaf)), + ModelSpec(ModelGraphCycleBModel(); name=:cycle_b) |> + AppliesTo(One(name=:leaf)), + ), + ) + lagged_without_initial_value = apply_model_graph_edit( + initialized_scene, + MarkModelPreviousTimeStep(:cycle_a, :y), + ) + lagged_row = only( + row for row in model_graph_view(lagged_without_initial_value).initialization + if row["applicationId"] == "cycle_a" && row["variable"] == "y" + ) + @test lagged_row["previousTimeStep"] + @test lagged_row["disposition"] == "unresolved" + initialized_break = apply_model_graph_edit( + initialized_scene, + BreakModelCycle(:cycle_a, :y, true, 0.25), + ) + @test !model_graph_view(initialized_break).metadata["cyclic"] + @test only(model_objects(initialized_break)).status.y == 0.25 +end + +@testset "CompositeModel graph edits preserve application configuration" begin + model = CompositeModel( + Object(:leaf; name=:leaf, scale=:Leaf, kind=:organ, status=Status(driver=1.0)); + applications=( + ModelSpec(ModelGraphSourceModel(); name=:source) |> + AppliesTo(One(name=:leaf)), + ), + ) + + configured = apply_model_graph_edit( + model, + SetModelApplicationEnvironment(:source, (provider=:model, sources=(T=:temperature,))), + ) + configured = apply_model_graph_edit( + configured, + SetModelOutputRouting(:source, :signal, :stream_only), + ) + configured = apply_model_graph_edit( + configured, + SetModelUpdateOrdering(:source, (Updates(:signal; after=:driver),)), + ) + spec = only(configured.applications) + @test environment_config(spec) == (provider=:model, sources=(T=:temperature,)) + @test output_routing(spec) == (signal=:stream_only,) + @test only(updates(spec)).variables == (:signal,) + configured_application = only(model_graph_view(configured).applications) + @test configured_application["environment"]["provider"] == "model" + @test configured_application["outputRouting"]["signal"] == "stream_only" + @test configured_application["updates"] == [Dict( + "variables" => ["signal"], + "after" => ["driver"], + )] + + ordered_writers = CompositeModel( + Object(:leaf; name=:leaf, scale=:Leaf, status=Status(driver=1.0)); + applications=( + ModelSpec(ModelGraphSourceModel(1.0); name=:first_writer) |> + AppliesTo(One(name=:leaf)), + ModelSpec(ModelGraphSourceModel(2.0); name=:second_writer) |> + AppliesTo(One(name=:leaf)) |> + Updates(:signal; after=:first_writer), + ), + ) + update_edge = only( + edge for edge in model_graph_view(ordered_writers).edges + if edge["kind"] == "update_order" + ) + @test update_edge["sourceApplicationId"] == "first_writer" + @test update_edge["targetApplicationId"] == "second_writer" + @test update_edge["variables"] == ["signal"] + + environment_scene = CompositeModel( + Object(:leaf; name=:leaf, scale=:Leaf); + applications=( + ModelSpec(ModelGraphEnvironmentModel(); name=:environment_user) |> + AppliesTo(One(name=:leaf)), + ), + ) + environment_edges = [ + edge for edge in model_graph_view(environment_scene).edges + if edge["kind"] == "environment_binding" && edge["projection"] == "applications" + ] + @test length(environment_edges) == 2 + @test Set(edge["sourceVariable"] for edge in environment_edges) == + Set(["T", "leaf_temperature"]) + @test all(haskey(edge, "provider") for edge in environment_edges) + + metadata = apply_model_graph_edit( + configured, + SetModelObjectMetadata(:leaf; scale=:Organ, kind=:leaf, species=:test, name=:leaf_1), + ) + object = only(model_objects(metadata)) + @test (object.scale, object.kind, object.species, object.name) == (:Organ, :leaf, :test, :leaf_1) + @test object_ids(metadata; scale=:Organ) == [ObjectId(:leaf)] + @test isempty(object_ids(metadata; scale=:Leaf)) + + without_status = apply_model_graph_edit(metadata, RemoveModelObjectStatus(:leaf, :driver)) + @test isnothing(only(model_objects(without_status)).status) + @test only(model_objects(metadata)).status.driver == 1.0 +end + +@testset "CompositeModel graph edit command coverage" begin + model = CompositeModel( + Object(:source_object; name=:source_object, scale=:Leaf, status=Status(driver=1.0)), + Object(:consumer_object; name=:consumer_object, scale=:Plant); + applications=( + ModelSpec(ModelGraphSourceModel(); name=:source) |> + AppliesTo(One(name=:source_object)), + ModelSpec(ModelGraphConsumerModel(); name=:consumer) |> + AppliesTo(One(name=:consumer_object)), + ), + ) + + configured = apply_model_graph_edit( + model, + SetModelApplicationTargets(:consumer, OptionalOne(name=:consumer_object)), + ) + configured = apply_model_graph_edit( + configured, + SetModelInputBinding( + :consumer, + :signal, + One(name=:source_object, application=:source, var=:signal), + ), + ) + configured = apply_model_graph_edit( + configured, + SetModelCallBinding( + :consumer, + :source_call, + One(name=:source_object, application=:source), + ), + ) + configured = apply_model_graph_edit( + configured, + SetModelApplicationTimeStep(:consumer, ClockSpec(2.0)), + ) + configured = apply_model_graph_edit( + configured, + SetModelUpdateOrdering(:consumer, (Updates(:result; after=:source),)), + ) + + consumer = PlantSimEngine._model_edit_spec(configured, :consumer) + @test applies_to(consumer) isa OptionalOne + @test PlantSimEngine.criteria(value_inputs(consumer).signal).application == :source + @test PlantSimEngine.criteria(model_calls(consumer).source_call).application == :source + @test consumer.timestep == ClockSpec(2.0) + consumer_view = only( + application for application in model_graph_view(configured).applications + if application["applicationId"] == "consumer" + ) + @test haskey(consumer_view["inputBindings"], "signal") + @test haskey(consumer_view["callBindings"], "source_call") + + renamed = apply_model_graph_edit(configured, RenameModelApplication(:source, :driver_source)) + renamed_consumer = PlantSimEngine._model_edit_spec(renamed, :consumer) + @test PlantSimEngine.criteria(value_inputs(renamed_consumer).signal).application == :driver_source + @test PlantSimEngine.criteria(model_calls(renamed_consumer).source_call).application == :driver_source + @test PlantSimEngine._update_after(only(updates(renamed_consumer))) == (:driver_source,) + @test_throws "already exists" apply_model_graph_edit( + renamed, + RenameModelApplication(:driver_source, :consumer), + ) + + without_input = apply_model_graph_edit( + renamed, + RemoveModelInputBinding(:consumer, :signal), + ) + @test isempty(value_inputs(PlantSimEngine._model_edit_spec(without_input, :consumer))) + without_call = apply_model_graph_edit( + without_input, + RemoveModelCallBinding(:consumer, :source_call), + ) + @test isempty(model_calls(PlantSimEngine._model_edit_spec(without_call, :consumer))) + + with_objects = apply_model_graph_edit( + without_call, + AddModelObject(Object(:child; name=:child, scale=:Leaf, parent=:consumer_object)), + ) + with_objects = apply_model_graph_edit( + with_objects, + ReparentModelObject(:child, :source_object), + ) + @test PlantSimEngine._model_object(with_objects, ObjectId(:child)).parent == ObjectId(:source_object) + with_objects = apply_model_graph_edit( + with_objects, + SetModelObjectStatuses([:source_object, :child], :shared_value, 5), + ) + @test all( + PlantSimEngine._model_object(with_objects, ObjectId(id)).status.shared_value == 5 + for id in (:source_object, :child) + ) + without_child = apply_model_graph_edit(with_objects, RemoveModelObject(:child)) + @test !(ObjectId(:child) in object_ids(without_child)) + @test ObjectId(:child) in object_ids(with_objects) +end + +function model_graph_override_fixture() + template = CompositeModelTemplate( + ( + ModelSpec(ModelGraphSourceModel(1.0); name=:source) |> + AppliesTo(Many(scale=:Leaf)), + ); + kind=:plant, + species=:test_species, + ) + return CompositeModel( + ObjectInstance( + :plant_a, + template; + root=Object(:plant_a; scale=:Plant), + objects=(Object(:leaf_a; scale=:Leaf, parent=:plant_a, status=Status(driver=1.0)),), + ), + ObjectInstance( + :plant_b, + template; + root=Object(:plant_b; scale=:Plant), + objects=(Object(:leaf_b; scale=:Leaf, parent=:plant_b, status=Status(driver=1.0)),), + ), + ) +end + +@testset "CompositeModel graph instance override edit" begin + model = model_graph_override_fixture() + @test length(model.instances) == 2 + _, instance = PlantSimEngine._model_edit_instance(model, :plant_b) + @test PlantSimEngine._model_edit_template_application_id(instance, :source) == :source + overrides = PlantSimEngine._model_edit_namedtuple_set( + instance.overrides, + :source, + ModelGraphSourceModel(2.0), + ) + @test haskey(overrides, :source) + replacement = PlantSimEngine._model_edit_normalize_instance(instance; overrides=overrides) + @test replacement.name == :plant_b + instance_override = apply_model_graph_edit( + model, + SetModelInstanceOverride(:plant_b, :source, ModelGraphSourceModel(2.0)), + ) + view = model_graph_view(instance_override) + plant_a = only(application for application in view.applications if application["applicationId"] == "plant_a__source") + plant_b = only(application for application in view.applications if application["applicationId"] == "plant_b__source") + @test plant_a["modelParameters"]["coefficient"]["value"] == 1.0 + @test plant_b["modelParameters"]["coefficient"]["value"] == 2.0 + + restored_instance = apply_model_graph_edit( + instance_override, + RemoveModelInstanceOverride(:plant_b, :source), + ) + restored_b = only( + application for application in model_graph_view(restored_instance).applications + if application["applicationId"] == "plant_b__source" + ) + @test restored_b["modelParameters"]["coefficient"]["value"] == 1.0 + @test model_graph_view(model).metadata["applicationCount"] == 2 + @test_throws Exception apply_model_graph_edit( + model, + SetModelInstanceOverride(:plant_b, :source, ModelGraphConsumerModel()), + ) +end + +@testset "CompositeModel graph object override edit" begin + model = model_graph_override_fixture() + @test length(model.instances) == 2 + object_override = apply_model_graph_edit( + model, + SetModelObjectOverride(:plant_a, :leaf_a, :source, ModelGraphSourceModel(3.0)), + ) + application = only( + application for application in model_graph_view(object_override).applications + if application["applicationId"] == "plant_a__source" + ) + @test application["modelStorage"] == "per_object_override" + @test only(application["objectOverrides"])["parameters"]["coefficient"]["value"] == 3.0 + + restored_object = apply_model_graph_edit( + object_override, + RemoveModelObjectOverride(:plant_a, :leaf_a, :source), + ) + restored_application = only( + application for application in model_graph_view(restored_object).applications + if application["applicationId"] == "plant_a__source" + ) + @test restored_application["modelStorage"] == "shared_application" + @test_throws Exception apply_model_graph_edit( + model, + SetModelObjectOverride(:plant_a, :leaf_a, :source, ModelGraphConsumerModel()), + ) +end + + +@testset "CompositeModel graph shared template application edit" begin + model = model_graph_override_fixture() + updated = apply_model_graph_edit( + model, + UpdateModelTemplateApplication( + :plant_a, + :plant_a__source, + ModelGraphSourceModel(4.0), + Many(scale=:Leaf), + ClockSpec(2.0), + ), + ) + applications = model_graph_view(updated).applications + @test Set(application["applicationId"] for application in applications) == + Set(["plant_a__source", "plant_b__source"]) + @test all( + application["modelParameters"]["coefficient"]["value"] == 4.0 + for application in applications + ) + @test all(application["targetCount"] == 1 for application in applications) + @test all(!isnothing(application["timestep"]) for application in applications) + @test all( + application["modelParameters"]["coefficient"]["value"] == 1.0 + for application in model_graph_view(model).applications + ) +end diff --git a/test/test-model-hard-calls.jl b/test/test-model-hard-calls.jl new file mode 100644 index 000000000..54f839393 --- /dev/null +++ b/test/test-model-hard-calls.jl @@ -0,0 +1,399 @@ +using Dates +using PlantSimEngine +using Test + +PlantSimEngine.@process "nested_call_leaf" verbose = false +PlantSimEngine.@process "nested_call_middle" verbose = false +PlantSimEngine.@process "nested_call_root" verbose = false +PlantSimEngine.@process "many_call_controller" verbose = false +PlantSimEngine.@process "call_return_shape" verbose = false + +struct NestedCallLeafModel <: AbstractNested_Call_LeafModel end +struct NestedCallMiddleModel <: AbstractNested_Call_MiddleModel end +struct NestedCallRootModel <: AbstractNested_Call_RootModel end +struct ManyCallControllerModel <: AbstractMany_Call_ControllerModel end +struct CallReturnShapeModel <: AbstractCall_Return_ShapeModel end + +const CALL_RETURN_CONTEXT = Ref{Any}() + +function call_lookup_allocations(context) + call_targets(context, :one) + return @allocated call_targets(context, :one) +end + +literal_call_targets(context::T) where {T} = call_targets(context, :one) + +PlantSimEngine.inputs_(::NestedCallLeafModel) = NamedTuple() +PlantSimEngine.outputs_(::NestedCallLeafModel) = (value=0.0, calls=0) + +function PlantSimEngine.run!( + ::NestedCallLeafModel, + models, + status, + meteo, + constants, + extra, +) + status.calls += 1 + status.value += 1.0 + return nothing +end + +PlantSimEngine.inputs_(::NestedCallMiddleModel) = NamedTuple() +PlantSimEngine.outputs_(::NestedCallMiddleModel) = (value=0.0, calls=0) + +function PlantSimEngine.run!( + ::NestedCallMiddleModel, + models, + status, + meteo, + constants, + extra, +) + status.calls += 1 + leaf = only(run_call!(extra, :leaf; publish=true)) + status.value = leaf.status.value + return nothing +end + +PlantSimEngine.inputs_(::NestedCallRootModel) = NamedTuple() +PlantSimEngine.outputs_(::NestedCallRootModel) = + (trial_value=0.0, accepted_value=0.0, calls=0) + +function PlantSimEngine.run!( + ::NestedCallRootModel, + models, + status, + meteo, + constants, + extra, +) + status.calls += 1 + middle = only(call_targets(extra, :middle)) + run_call!(middle; publish=false) + status.trial_value = middle.status.value + run_call!(middle; publish=true) + status.accepted_value = middle.status.value + return nothing +end + +PlantSimEngine.inputs_(::ManyCallControllerModel) = NamedTuple() +PlantSimEngine.outputs_(::ManyCallControllerModel) = (total=0.0, ncalls=0) + +function PlantSimEngine.run!( + ::ManyCallControllerModel, + models, + status, + meteo, + constants, + extra, +) + targets = run_call!(extra, :children; publish=true) + status.ncalls = length(targets) + status.total = sum((target.status.value for target in targets); init=0.0) + return nothing +end + +PlantSimEngine.inputs_(::CallReturnShapeModel) = NamedTuple() +PlantSimEngine.outputs_(::CallReturnShapeModel) = ( + one_count=0, + optional_count=0, + many_count=0, + all_vector_like=false, + cached_view=false, +) + +function PlantSimEngine.run!( + ::CallReturnShapeModel, + models, + status, + meteo, + constants, + extra, +) + one_targets = run_call!(extra, :one; publish=false) + optional_targets = run_call!(extra, :optional; publish=false) + many_targets = run_call!(extra, :many; publish=false) + status.one_count = length(one_targets) + status.optional_count = length(optional_targets) + status.many_count = length(many_targets) + status.all_vector_like = all( + targets -> targets isa AbstractVector{CallTarget}, + (one_targets, optional_targets, many_targets), + ) + status.cached_view = call_targets(extra, :one) === call_targets(extra, :one) + CALL_RETURN_CONTEXT[] = extra + return nothing +end + +@testset "nested trial publication is transactional" begin + model = CompositeModel( + Object(:scene; scale=:Scene, name=:scene), + Object(:middle; scale=:Plant, name=:middle, parent=:scene), + Object(:leaf; scale=:Leaf, name=:leaf, parent=:middle); + applications=( + ModelSpec(NestedCallRootModel(); name=:root) |> + AppliesTo(One(name=:scene)) |> + Calls(:middle => One(name=:middle, within=Subtree(), application=:middle)) |> + TimeStep(Hour(1)), + ModelSpec(NestedCallMiddleModel(); name=:middle) |> + AppliesTo(One(name=:middle)) |> + Calls(:leaf => One(name=:leaf, within=Subtree(), application=:leaf)) |> + TimeStep(Hour(1)), + ModelSpec(NestedCallLeafModel(); name=:leaf) |> + AppliesTo(One(name=:leaf)) |> + TimeStep(Hour(1)), + ), + environment=(duration=Hour(1),), + ) + + simulation = run!(model; outputs=:all) + statuses = Dict(object.id.value => object.status for object in model_objects(model)) + @test statuses[:leaf].calls == 2 + @test statuses[:leaf].value == 2.0 + @test statuses[:middle].calls == 2 + @test statuses[:middle].value == 2.0 + @test statuses[:scene].trial_value == 1.0 + @test statuses[:scene].accepted_value == 2.0 + + @test length(outputs(simulation)[(:leaf, ObjectId(:leaf), :value)]) == 1 + @test length(outputs(simulation)[(:middle, ObjectId(:middle), :value)]) == 1 + @test only(outputs(simulation)[(:leaf, ObjectId(:leaf), :value)])[2] == 2.0 + @test only(outputs(simulation)[(:middle, ObjectId(:middle), :value)])[2] == 2.0 + + schedule = Dict(row.application_id => row for row in explain_schedule(simulation.compiled)) + @test schedule[:middle].manual_call_only + @test schedule[:leaf].manual_call_only + @test !schedule[:root].manual_call_only +end + + +@testset "hard-call return shape and errors" begin + model = CompositeModel( + Object(:scene; scale=:Scene, name=:scene), + Object(:leaf_b; scale=:Leaf, name=:leaf_b, parent=:scene), + Object(:leaf_a; scale=:Leaf, name=:leaf_a, parent=:scene); + applications=( + ModelSpec(CallReturnShapeModel(); name=:controller) |> + AppliesTo(One(name=:scene)) |> + Calls( + :one => One(name=:leaf_a, application=:leaf_calls), + :optional => OptionalOne( + name=:missing, + application=:leaf_calls, + ), + :many => Many(scale=:Leaf, application=:leaf_calls), + ), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls) |> + AppliesTo(Many(scale=:Leaf)), + ), + environment=(duration=Hour(1),), + ) + + run!(model) + controller = only(model_objects(model; scale=:Scene)).status + @test controller.one_count == 1 + @test controller.optional_count == 0 + @test controller.many_count == 2 + @test controller.all_vector_like + @test controller.cached_view + context = CALL_RETURN_CONTEXT[] + @test (@inferred literal_call_targets(context)) === call_targets(context, :one) + call_lookup_allocations(context) + @test call_lookup_allocations(context) == 0 + @test_throws ArgumentError run_call!(nothing, :one) + + undeclared = CompositeModel( + Object(:scene; scale=:Scene); + applications=( + ModelSpec(CallReturnShapeModel(); name=:controller) |> + AppliesTo(One(scale=:Scene)), + ), + environment=(duration=Hour(1),), + ) + @test_throws "did not declare call `one`" run!(undeclared) + + zero_one = CompositeModel( + Object(:scene; scale=:Scene); + applications=( + ModelSpec(CallReturnShapeModel(); name=:controller) |> + AppliesTo(One(scale=:Scene)) |> + Calls(:one => One(scale=:Leaf, application=:leaf_calls)), + ), + ) + @test_throws "Expected exactly one object" Advanced.refresh_bindings!(zero_one) + + multiple_one = CompositeModel( + Object(:scene; scale=:Scene), + Object(:leaf_a; scale=:Leaf, parent=:scene), + Object(:leaf_b; scale=:Leaf, parent=:scene); + applications=( + ModelSpec(CallReturnShapeModel(); name=:controller) |> + AppliesTo(One(scale=:Scene)) |> + Calls(:one => One(scale=:Leaf, application=:leaf_calls)), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls) |> + AppliesTo(Many(scale=:Leaf)), + ), + ) + @test_throws "Expected exactly one object" Advanced.refresh_bindings!(multiple_one) + + ambiguous_applications = CompositeModel( + Object(:scene; scale=:Scene), + Object(:leaf; scale=:Leaf, parent=:scene); + applications=( + ModelSpec(CallReturnShapeModel(); name=:controller) |> + AppliesTo(One(scale=:Scene)) |> + Calls(:one => One(scale=:Leaf, process=:nested_call_leaf)), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls_a) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls_b) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + @test_throws "expected one callee application" Advanced.refresh_bindings!( + ambiguous_applications, + ) + + ambiguous_optional = CompositeModel( + Object(:scene; scale=:Scene), + Object(:leaf; scale=:Leaf, parent=:scene); + applications=( + ModelSpec(CallReturnShapeModel(); name=:controller) |> + AppliesTo(One(scale=:Scene)) |> + Calls( + :optional => OptionalOne( + scale=:Leaf, + process=:nested_call_leaf, + ), + ), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls_a) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls_b) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + @test_throws "expected zero or one callee application" Advanced.refresh_bindings!( + ambiguous_optional, + ) +end + +@testset "Many call targets preserve object identity" begin + model = CompositeModel( + Object(:scene; scale=:Scene, name=:scene), + Object(:leaf_b; scale=:Leaf, parent=:scene), + Object(:leaf_a; scale=:Leaf, parent=:scene); + applications=( + ModelSpec(ManyCallControllerModel(); name=:controller) |> + AppliesTo(One(name=:scene)) |> + Calls( + :children => Many( + scale=:Leaf, + within=SceneScope(), + application=:leaf_calls, + ), + ), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls) |> + AppliesTo(Many(scale=:Leaf)), + ), + environment=(duration=Hour(1),), + ) + + compiled = Advanced.refresh_bindings!(model) + call = only(explain_calls(compiled)) + @test call.callee_object_ids == [:leaf_a, :leaf_b] + @test call.callee_application_ids == [:leaf_calls] + + simulation = run!(model; outputs=:all) + controller = only(model_objects(model; scale=:Scene)).status + @test controller.ncalls == 2 + @test controller.total == 2.0 + rows = filter( + row -> row.application_id == :leaf_calls && row.variable == :value, + collect_outputs(simulation; sink=nothing), + ) + @test getproperty.(rows, :object_id) == [:leaf_a, :leaf_b] + @test getproperty.(rows, :value) == [1.0, 1.0] + + register_object!( + model, + Object(:leaf_c; scale=:Leaf, parent=:scene), + ) + continue!(simulation; steps=1) + @test controller.ncalls == 3 + @test controller.total == 5.0 + + remove_object!(model, :leaf_b) + continue!(simulation; steps=1) + @test controller.ncalls == 2 + @test controller.total == 5.0 +end + +@testset "call targets refresh after reparenting" begin + model = CompositeModel( + Object(:scene; scale=:Scene), + Object(:plant_a; scale=:Plant, name=:plant_a, parent=:scene), + Object(:plant_b; scale=:Plant, name=:plant_b, parent=:scene), + Object(:leaf; scale=:Leaf, parent=:plant_b); + applications=( + ModelSpec(ManyCallControllerModel(); name=:controller) |> + AppliesTo(One(name=:plant_a)) |> + Calls( + :children => Many( + scale=:Leaf, + within=Subtree(), + application=:leaf_calls, + ), + ), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls) |> + AppliesTo(Many(scale=:Leaf)), + ), + environment=(duration=Hour(1),), + ) + + simulation = run!(model) + controller = only(model_objects(model; name=:plant_a)).status + @test controller.ncalls == 0 + + reparent_object!(model, :leaf, :plant_a) + continue!(simulation; steps=1) + @test controller.ncalls == 1 + @test controller.total == 2.0 + + reparent_object!(model, :leaf, :plant_b) + continue!(simulation; steps=1) + @test controller.ncalls == 0 + @test controller.total == 0.0 +end + +@testset "manual target cadence contract" begin + incompatible = CompositeModel( + Object(:scene; scale=:Scene, name=:scene), + Object(:leaf; scale=:Leaf, name=:leaf, parent=:scene); + applications=( + ModelSpec(ManyCallControllerModel(); name=:controller) |> + AppliesTo(One(name=:scene)) |> + Calls(:children => One(name=:leaf, application=:leaf_calls)) |> + TimeStep(Day(1)), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls) |> + AppliesTo(One(name=:leaf)) |> + TimeStep(Hour(1)), + ), + environment=(duration=Hour(1),), + ) + @test_throws "incompatible cadence" Advanced.refresh_bindings!(incompatible) + + inherited = CompositeModel( + Object(:scene; scale=:Scene, name=:scene), + Object(:leaf; scale=:Leaf, name=:leaf, parent=:scene); + applications=( + ModelSpec(ManyCallControllerModel(); name=:controller) |> + AppliesTo(One(name=:scene)) |> + Calls(:children => One(name=:leaf, application=:leaf_calls)) |> + TimeStep(Day(1)), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls) |> + AppliesTo(One(name=:leaf)), + ), + environment=(duration=Hour(1),), + ) + @test_nowarn Advanced.refresh_bindings!(inherited) +end diff --git a/test/test-model-meteo-sampling.jl b/test/test-model-meteo-sampling.jl new file mode 100644 index 000000000..8a747ef31 --- /dev/null +++ b/test/test-model-meteo-sampling.jl @@ -0,0 +1,116 @@ +using Dates +using PlantMeteo +using PlantSimEngine +using Test + +PlantSimEngine.@process "meteo_sampling_probe" verbose = false +struct MeteoSamplingProbeModel <: AbstractMeteo_Sampling_ProbeModel end +PlantSimEngine.inputs_(::MeteoSamplingProbeModel) = NamedTuple() +PlantSimEngine.outputs_(::MeteoSamplingProbeModel) = ( + mean_T=0.0, + min_T=0.0, + max_T=0.0, + mean_Rh=0.0, + mean_radiation=0.0, + radiation_energy=0.0, +) +PlantSimEngine.meteo_inputs_(::MeteoSamplingProbeModel) = ( + T=0.0, + Tmin=0.0, + Tmax=0.0, + Rh=0.0, + Ri_SW_f=0.0, + Ri_SW_q=0.0, +) +PlantSimEngine.meteo_hint(::Type{<:MeteoSamplingProbeModel}) = ( + window=PlantMeteo.RollingWindow(2.0), + bindings=( + T=(source=:T, reducer=MeanWeighted()), + Tmin=(source=:T, reducer=MinReducer()), + Tmax=(source=:T, reducer=MaxReducer()), + Rh=(source=:Rh, reducer=MeanWeighted()), + Ri_SW_f=(source=:Ri_SW_f, reducer=MeanWeighted()), + Ri_SW_q=(source=:Ri_SW_f, reducer=RadiationEnergy()), + ), +) +function PlantSimEngine.run!(::MeteoSamplingProbeModel, models, status, meteo, constants, extra) + status.mean_T = meteo.T + status.min_T = meteo.Tmin + status.max_T = meteo.Tmax + status.mean_Rh = meteo.Rh + status.mean_radiation = meteo.Ri_SW_f + status.radiation_energy = meteo.Ri_SW_q +end + +@testset "meteorological aggregation and model hint" begin + if PlantSimEngine._has_meteo_sampler_api() + base_date = DateTime(2025, 1, 1) + weather = Weather([ + Atmosphere(date=base_date, T=10.0, Wind=1.0, Rh=0.5, P=100.0, Ri_SW_f=100.0, duration=Hour(1)), + Atmosphere(date=base_date + Hour(1), T=20.0, Wind=1.0, Rh=0.6, P=100.0, Ri_SW_f=200.0, duration=Hour(1)), + Atmosphere(date=base_date + Hour(2), T=30.0, Wind=1.0, Rh=0.7, P=100.0, Ri_SW_f=300.0, duration=Hour(1)), + Atmosphere(date=base_date + Hour(3), T=40.0, Wind=1.0, Rh=0.8, P=100.0, Ri_SW_f=400.0, duration=Hour(1)), + ]) + model = CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec(MeteoSamplingProbeModel(); name=:probe) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(2)) |> + Environment(provider=:global), + ), + environment=weather, + ) + bindings = only(explain_environment_bindings(Advanced.refresh_environment_bindings!(model))) + @test bindings.temporal_sampler + spec = Advanced.refresh_bindings!(model).applications_by_id[:probe].spec + @test meteo_bindings(spec).Ri_SW_q.reducer isa RadiationEnergy + simulation = run!(model; steps=4, outputs=:all) + values(variable) = getproperty.( + collect_outputs(simulation, :leaf, variable; sink=nothing), + :value, + ) + @test values(:mean_T) == [10.0, 25.0] + @test values(:min_T) == [10.0, 20.0] + @test values(:max_T) == [10.0, 30.0] + @test values(:mean_Rh) == [0.5, 0.65] + @test values(:mean_radiation) == [100.0, 250.0] + @test values(:radiation_energy) ≈ [0.36, 1.8] + + template = CompositeModelTemplate(( + ModelSpec(MeteoSamplingProbeModel(); name=:probe) |> + AppliesTo(Many(scale=:Leaf)) |> + TimeStep(Hour(2)) |> + Environment(provider=:global), + )) + instance = ObjectInstance( + :plant, + template; + root=Object(:plant_root; scale=:Plant), + objects=( + Object(:leaf_a; scale=:Leaf, parent=:plant_root), + Object(:leaf_b; scale=:Leaf, parent=:plant_root), + ), + object_overrides=( + Override( + object=:leaf_b, + application=:probe, + model=MeteoSamplingProbeModel(), + ), + ), + ) + override_scene = CompositeModel(instance; environment=weather) + override_compiled = Advanced.refresh_bindings!(override_scene) + override_spec = override_compiled.applications_by_id[:plant__probe].spec + @test meteo_window(override_spec) isa PlantMeteo.RollingWindow + @test meteo_window(override_spec).dt == 2.0 + @test meteo_bindings(override_spec).Ri_SW_q.reducer isa RadiationEnergy + run!(override_scene; steps=4) + for object in model_objects(override_scene; scale=:Leaf) + @test object.status.mean_T == 25.0 + @test object.status.radiation_energy ≈ 1.8 + end + else + @test_skip "PlantMeteo weather sampler API unavailable" + end +end diff --git a/test/test-model-multirate-integration.jl b/test/test-model-multirate-integration.jl new file mode 100644 index 000000000..2576362dd --- /dev/null +++ b/test/test-model-multirate-integration.jl @@ -0,0 +1,88 @@ +using Dates +using PlantSimEngine +using Test + +PlantSimEngine.@process "hourly_leaf_flux" verbose = false +PlantSimEngine.@process "daily_plant_flux" verbose = false +PlantSimEngine.@process "daily_soil_state" verbose = false + +struct HourlyLeafFluxModel <: AbstractHourly_Leaf_FluxModel end +struct DailyPlantFluxModel <: AbstractDaily_Plant_FluxModel end +struct DailySoilStateModel <: AbstractDaily_Soil_StateModel end +PlantSimEngine.inputs_(::HourlyLeafFluxModel) = (rate=0.0,) +PlantSimEngine.outputs_(::HourlyLeafFluxModel) = (flux=0.0, hourly_runs=0) +function PlantSimEngine.run!(::HourlyLeafFluxModel, models, status, meteo, constants, extra) + status.flux = status.rate + status.hourly_runs += 1 +end +PlantSimEngine.inputs_(::DailyPlantFluxModel) = (leaf_fluxes=[0.0],) +PlantSimEngine.outputs_(::DailyPlantFluxModel) = (daily_total=0.0, daily_runs=0) +function PlantSimEngine.run!(::DailyPlantFluxModel, models, status, meteo, constants, extra) + status.daily_total = sum(status.leaf_fluxes) + status.daily_runs += 1 +end +PlantSimEngine.inputs_(::DailySoilStateModel) = NamedTuple() +PlantSimEngine.outputs_(::DailySoilStateModel) = (soil_runs=0,) +function PlantSimEngine.run!(::DailySoilStateModel, models, status, meteo, constants, extra) + status.soil_runs += 1 +end + +@testset "48-hour hourly/daily stack and plant isolation" begin + model = CompositeModel( + Object(:scene; scale=:Scene), + Object(:soil; scale=:Soil, parent=:scene), + Object(:plant_1; scale=:Plant, parent=:scene), + Object(:plant_1_leaf_1; scale=:Leaf, parent=:plant_1, status=Status(rate=1.0)), + Object(:plant_1_leaf_2; scale=:Leaf, parent=:plant_1, status=Status(rate=2.0)), + Object(:plant_2; scale=:Plant, parent=:scene), + Object(:plant_2_leaf_1; scale=:Leaf, parent=:plant_2, status=Status(rate=10.0)), + Object(:plant_2_leaf_2; scale=:Leaf, parent=:plant_2, status=Status(rate=20.0)); + applications=( + ModelSpec(HourlyLeafFluxModel(); name=:hourly_flux) |> + AppliesTo(Many(scale=:Leaf)) |> + TimeStep(Hour(1)), + ModelSpec(DailyPlantFluxModel(); name=:daily_plant) |> + AppliesTo(Many(scale=:Plant)) |> + Inputs(:leaf_fluxes => Many( + scale=:Leaf, + within=Subtree(), + application=:hourly_flux, + var=:flux, + policy=Integrate(), + window=Day(1), + )) |> + TimeStep(Day(1)), + ModelSpec(DailySoilStateModel(); name=:daily_soil) |> + AppliesTo(One(scale=:Soil)) |> + TimeStep(Day(1)), + ), + environment=[(duration=Hour(1),) for _ in 1:48], + ) + compiled = Advanced.refresh_bindings!(model) + schedule = Dict(row.application_id => row for row in explain_schedule(compiled)) + @test schedule[:hourly_flux].dt_seconds == 3600.0 + @test schedule[:daily_plant].dt_seconds == 86_400.0 + @test schedule[:daily_soil].dt_steps == 24.0 + + simulation = run!(model; steps=23, outputs=:all) + continue!(simulation; steps=25) + @test current_step(simulation) == 48 + statuses = Dict(object.id.value => object.status for object in model_objects(model)) + @test all(statuses[id].hourly_runs == 48 for id in + (:plant_1_leaf_1, :plant_1_leaf_2, :plant_2_leaf_1, :plant_2_leaf_2)) + @test statuses[:plant_1].daily_runs == 2 + @test statuses[:plant_2].daily_runs == 2 + @test statuses[:soil].soil_runs == 2 + @test statuses[:plant_1].daily_total == 72.0 + @test statuses[:plant_2].daily_total == 720.0 + + plant_1_values = last.(outputs(simulation)[ + (:daily_plant, ObjectId(:plant_1), :daily_total) + ]) + plant_2_values = last.(outputs(simulation)[ + (:daily_plant, ObjectId(:plant_2), :daily_total) + ]) + @test plant_1_values == [3.0, 72.0] + @test plant_2_values == [30.0, 720.0] + @test all(isfinite, vcat(plant_1_values, plant_2_values)) +end diff --git a/test/test-model-numerical-parity.jl b/test/test-model-numerical-parity.jl new file mode 100644 index 000000000..40c990b57 --- /dev/null +++ b/test/test-model-numerical-parity.jl @@ -0,0 +1,240 @@ +using Dates +using PlantSimEngine +using Test + +PlantSimEngine.@process "parity_forcing" verbose = false +PlantSimEngine.@process "parity_stage_one" verbose = false +PlantSimEngine.@process "parity_stage_two" verbose = false +PlantSimEngine.@process "parity_stage_three" verbose = false + +struct ParityForcingModel <: AbstractParity_ForcingModel end +struct ParityStageOneModel{T} <: AbstractParity_Stage_OneModel + a::T +end +struct ParityStageTwoModel <: AbstractParity_Stage_TwoModel end +struct ParityStageThreeModel <: AbstractParity_Stage_ThreeModel end + +PlantSimEngine.@process "parity_stage_five" verbose = false +PlantSimEngine.@process "parity_stage_six" verbose = false +PlantSimEngine.@process "parity_soil" verbose = false +PlantSimEngine.@process "parity_gather" verbose = false +PlantSimEngine.@process "parity_receiver" verbose = false + +struct ParityStageFiveModel <: AbstractParity_Stage_FiveModel end +struct ParityStageSixModel <: AbstractParity_Stage_SixModel end +struct ParitySoilModel <: AbstractParity_SoilModel end +struct ParityGatherModel <: AbstractParity_GatherModel end +struct ParityReceiverModel <: AbstractParity_ReceiverModel end + +PlantSimEngine.inputs_(::ParityForcingModel) = NamedTuple() +PlantSimEngine.outputs_(::ParityForcingModel) = (var1=0.0,) +PlantSimEngine.meteo_inputs_(::ParityForcingModel) = (forcing=0.0,) +function PlantSimEngine.run!(::ParityForcingModel, models, status, meteo, constants, extra) + status.var1 = meteo.forcing + return nothing +end + +PlantSimEngine.inputs_(::ParityStageOneModel) = (var1=0.0, var2=0.0) +PlantSimEngine.outputs_(::ParityStageOneModel) = (var3=0.0,) +function PlantSimEngine.run!(model::ParityStageOneModel, models, status, meteo, constants, extra) + status.var3 = model.a + status.var1 * status.var2 + return nothing +end + +PlantSimEngine.inputs_(::ParityStageTwoModel) = (var3=0.0,) +PlantSimEngine.outputs_(::ParityStageTwoModel) = (raw_var4=0.0, var5=0.0) +PlantSimEngine.meteo_inputs_(::ParityStageTwoModel) = (T=0.0, Wind=0.0, Rh=0.0) +function PlantSimEngine.run!(::ParityStageTwoModel, models, status, meteo, constants, extra) + status.raw_var4 = status.var3 * 2 + status.var5 = status.raw_var4 + meteo.T + 2 * meteo.Wind + 3 * meteo.Rh + return nothing +end + +PlantSimEngine.inputs_(::ParityStageThreeModel) = (raw_var4=0.0, var5=0.0) +PlantSimEngine.outputs_(::ParityStageThreeModel) = (var4=0.0, var6=0.0) +function PlantSimEngine.run!(::ParityStageThreeModel, models, status, meteo, constants, extra) + status.var4 = status.raw_var4 * 2 + status.var6 = status.var5 + status.var4 + return nothing +end + +PlantSimEngine.inputs_(::ParityStageFiveModel) = (var5=0.0, var6=0.0) +PlantSimEngine.outputs_(::ParityStageFiveModel) = (var7=0.0,) +function PlantSimEngine.run!(::ParityStageFiveModel, models, status, meteo, constants, extra) + status.var7 = status.var5 * status.var6 +end +PlantSimEngine.inputs_(::ParityStageSixModel) = (var7=0.0,) +PlantSimEngine.outputs_(::ParityStageSixModel) = (var8=0.0,) +function PlantSimEngine.run!(::ParityStageSixModel, models, status, meteo, constants, extra) + status.var8 = status.var7 + 1 +end +PlantSimEngine.inputs_(::ParitySoilModel) = NamedTuple() +PlantSimEngine.outputs_(::ParitySoilModel) = (soil_value=1.0,) +PlantSimEngine.run!(::ParitySoilModel, models, status, meteo, constants, extra) = nothing +PlantSimEngine.inputs_(::ParityGatherModel) = (leaf_values=[0.0], soil_value=0.0) +PlantSimEngine.outputs_(::ParityGatherModel) = (gathered=0.0, scattered=0.0) +function PlantSimEngine.run!(::ParityGatherModel, models, status, meteo, constants, extra) + status.gathered = sum(status.leaf_values) + status.soil_value + status.scattered = first(status.leaf_values) +end +PlantSimEngine.inputs_(::ParityReceiverModel) = (shared=0.0,) +PlantSimEngine.outputs_(::ParityReceiverModel) = (received=0.0,) +function PlantSimEngine.run!(::ParityReceiverModel, models, status, meteo, constants, extra) + status.received = status.shared +end + +parity_weather = [ + (forcing=15.0, T=20.0, Wind=1.0, Rh=0.65, duration=Hour(1)), + (forcing=16.0, T=25.0, Wind=0.5, Rh=0.8, duration=Hour(1)), +] + +function parity_applications(selector) + return ( + ModelSpec(ParityForcingModel(); name=:forcing) |> AppliesTo(selector), + ModelSpec(ParityStageOneModel(1.0); name=:stage_one) |> AppliesTo(selector), + ModelSpec(ParityStageTwoModel(); name=:stage_two) |> AppliesTo(selector), + ModelSpec(ParityStageThreeModel(); name=:stage_three) |> AppliesTo(selector), + ) +end + +function parity_state(status) + return [status.var5, status.var4, status.var6, status.var1, status.var3, status.var2] +end + +@testset "exact one-object process chain" begin + one_step = CompositeModel( + Object(:leaf; scale=:Leaf, status=Status(var2=0.3)); + applications=parity_applications(One(scale=:Leaf)), + environment=parity_weather[1:1], + ) + run!(one_step) + @test parity_state(only(model_objects(one_step)).status) == [34.95, 22.0, 56.95, 15.0, 5.5, 0.3] + + two_steps = CompositeModel( + Object(:leaf; scale=:Leaf, status=Status(var2=0.3)); + applications=parity_applications(One(scale=:Leaf)), + environment=parity_weather, + ) + simulation = run!(two_steps; steps=2, outputs=:all) + @test parity_state(only(model_objects(two_steps)).status) == [40.0, 23.2, 63.2, 16.0, 5.8, 0.3] + @test last.(outputs(simulation)[(:stage_three, ObjectId(:leaf), :var6)]) == [56.95, 63.2] +end + +@testset "one application with an object-specific Override" begin + template = CompositeModelTemplate( + parity_applications(Many(scale=:Leaf)); + kind=:plant, + ) + instance = ObjectInstance( + :plant, + template; + root=Object(:plant; scale=:Plant), + objects=( + Object(:leaf_default; scale=:Leaf, parent=:plant, status=Status(var2=0.3)), + Object(:leaf_override; scale=:Leaf, parent=:plant, status=Status(var2=0.3)), + ), + object_overrides=( + Override( + object=:leaf_override, + application=:stage_one, + model=ParityStageOneModel(2.0), + ), + ), + ) + model = CompositeModel(instance; environment=parity_weather) + simulation = run!(model; steps=2, outputs=:all) + statuses = Dict(object.id.value => object.status for object in model_objects(model)) + @test parity_state(statuses[:leaf_default]) == [40.0, 23.2, 63.2, 16.0, 5.8, 0.3] + @test parity_state(statuses[:leaf_override]) == [42.0, 27.2, 69.2, 16.0, 6.8, 0.3] + + override_rows = filter( + row -> row.object_id == :leaf_override && row.variable in (:var5, :var4, :var6), + collect_outputs(simulation; sink=nothing), + ) + @test length(override_rows) == 6 +end + + +@testset "exact multiscale gather and scatter chain" begin + model = CompositeModel( + Object(:scene; scale=:Scene), + Object(:soil; scale=:Soil, parent=:scene), + Object(:plant; scale=:Plant, parent=:scene), + Object(:internode_1; scale=:Internode, parent=:plant), + Object(:leaf_1; scale=:Leaf, parent=:internode_1, status=Status(var2=1.03)), + Object(:internode_2; scale=:Internode, parent=:internode_1), + Object(:leaf_2; scale=:Leaf, parent=:internode_2, status=Status(var2=1.03)); + applications=( + ModelSpec(ParitySoilModel(); name=:soil_source) |> + AppliesTo(One(scale=:Soil)), + ModelSpec(ParityForcingModel(); name=:forcing) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ParityStageOneModel(1.0); name=:stage_one) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ParityStageTwoModel(); name=:stage_two) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ParityStageThreeModel(); name=:stage_three) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ParityStageFiveModel(); name=:stage_five) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ParityStageSixModel(); name=:stage_six) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ParityGatherModel(); name=:plant_gather) |> + AppliesTo(One(scale=:Plant)) |> + Inputs( + :leaf_values => Many( + scale=:Leaf, + within=Subtree(), + application=:stage_six, + var=:var8, + ), + :soil_value => One( + scale=:Soil, + within=SceneScope(), + application=:soil_source, + var=:soil_value, + ), + ), + ModelSpec(ParityReceiverModel(); name=:leaf_receiver) |> + AppliesTo(Many(scale=:Leaf)) |> + Inputs(:shared => One( + scale=:Plant, + within=Ancestor(scale=:Plant), + application=:plant_gather, + var=:scattered, + )), + ModelSpec(ParityReceiverModel(); name=:internode_receiver) |> + AppliesTo(Many(scale=:Internode)) |> + Inputs(:shared => One( + scale=:Plant, + within=Ancestor(scale=:Plant), + application=:plant_gather, + var=:scattered, + )), + ), + environment=[ + (forcing=1.01, T=20.0, Wind=1.0, Rh=0.65, duration=Hour(1)), + (forcing=1.01, T=25.0, Wind=0.5, Rh=0.8, duration=Hour(1)), + ], + ) + simulation = run!(model; steps=2, outputs=:all) + leaves = model_objects(model; scale=:Leaf) + @test getproperty.(leaves, :id) == [ObjectId(:leaf_1), ObjectId(:leaf_2)] + for leaf in leaves + @test leaf.status.var1 === 1.01 + @test leaf.status.var2 === 1.03 + @test leaf.status.var4 ≈ 8.1612000000000013 atol=1e-6 + @test leaf.status.var5 == 32.4806 + @test leaf.status.var8 ≈ 1321.0700490800002 atol=1e-6 + @test leaf.status.received == leaf.status.var8 + end + plant = only(model_objects(model; scale=:Plant)).status + @test plant.gathered ≈ 2 * 1321.0700490800002 + 1 atol=1e-6 + @test all( + object -> object.status.received ≈ 1321.0700490800002, + model_objects(model; scale=:Internode), + ) + rows = filter(row -> row.variable == :var8, collect_outputs(simulation; sink=nothing)) + @test length(rows) == 4 + @test Set(getproperty.(rows, :object_id)) == Set((:leaf_1, :leaf_2)) +end diff --git a/test/test-model-output-boundaries.jl b/test/test-model-output-boundaries.jl new file mode 100644 index 000000000..e09c0aa3d --- /dev/null +++ b/test/test-model-output-boundaries.jl @@ -0,0 +1,66 @@ +using Dates +using PlantSimEngine +using Test + +PlantSimEngine.@process "boundary_counter" verbose = false +struct BoundaryCounterModel <: AbstractBoundary_CounterModel end +PlantSimEngine.inputs_(::BoundaryCounterModel) = NamedTuple() +PlantSimEngine.outputs_(::BoundaryCounterModel) = (count=0, ignored=0) +function PlantSimEngine.run!(::BoundaryCounterModel, models, status, meteo, constants, extra) + status.count += 1 + status.ignored += 10 +end + +@testset "one step over several objects" begin + model = CompositeModel( + Object(:leaf_b; scale=:Leaf), + Object(:leaf_a; scale=:Leaf); + applications=( + ModelSpec(BoundaryCounterModel(); name=:leaf_counter) |> + AppliesTo(Many(scale=:Leaf)), + ), + environment=(duration=Hour(1),), + ) + simulation = run!( + model; + outputs=OutputRequest(:Leaf, :count; name=:leaf_counts), + ) + requested = collect_outputs(simulation, :leaf_counts; sink=nothing) + @test length(requested) == 2 + @test getproperty.(requested, :object_id) == [:leaf_a, :leaf_b] + @test unique(getproperty.(requested, :timestep)) == [1] + @test unique(getproperty.(requested, :application_id)) == [:leaf_counter] + + selected = collect_outputs(simulation, :leaf_counts; sink=nothing) + @test length(selected) == 2 + @test all(row -> row.variable == :count, selected) + @test Set(keys(outputs(simulation))) == Set([ + (:leaf_counter, ObjectId(:leaf_a), :count), + (:leaf_counter, ObjectId(:leaf_b), :count), + ]) +end + +@testset "object count multiplied by cadence" begin + model = CompositeModel( + Object(:leaf_2; scale=:Leaf), + Object(:leaf_1; scale=:Leaf), + Object(:plant; scale=:Plant); + applications=( + ModelSpec(BoundaryCounterModel(); name=:hourly_leaves) |> + AppliesTo(Many(scale=:Leaf)) |> + TimeStep(Hour(1)), + ModelSpec(BoundaryCounterModel(); name=:daily_plant) |> + AppliesTo(One(scale=:Plant)) |> + TimeStep(Day(1)), + ), + environment=[(duration=Hour(1),) for _ in 1:48], + ) + simulation = run!(model; steps=48, outputs=:all) + rows = collect_outputs(simulation; sink=nothing) + leaf_rows = filter(row -> row.application_id == :hourly_leaves && row.variable == :count, rows) + plant_rows = filter(row -> row.application_id == :daily_plant && row.variable == :count, rows) + @test length(leaf_rows) == 2 * 48 + @test length(plant_rows) == 2 + @test Set(getproperty.(leaf_rows, :object_id)) == Set((:leaf_1, :leaf_2)) + @test all(row -> row.object_id == :plant, plant_rows) +end diff --git a/test/test-model-runtime-matrix.jl b/test/test-model-runtime-matrix.jl new file mode 100644 index 000000000..d2391f5fd --- /dev/null +++ b/test/test-model-runtime-matrix.jl @@ -0,0 +1,99 @@ +using Dates +using PlantSimEngine +using Test + +PlantSimEngine.@process "runtime_matrix_probe" verbose = false +struct RuntimeMatrixProbeModel <: AbstractRuntime_Matrix_ProbeModel end +PlantSimEngine.inputs_(::RuntimeMatrixProbeModel) = NamedTuple() +PlantSimEngine.outputs_(::RuntimeMatrixProbeModel) = (seen=0.0,) +PlantSimEngine.meteo_inputs_(::RuntimeMatrixProbeModel) = (T=0.0,) +function PlantSimEngine.run!(::RuntimeMatrixProbeModel, models, status, meteo, constants, extra) + status.seen = meteo.T +end + +function runtime_matrix_scene(environment) + return CompositeModel( + Object(:probe; scale=:Leaf); + applications=( + ModelSpec(RuntimeMatrixProbeModel(); name=:probe) |> + AppliesTo(One(scale=:Leaf)), + ), + environment=environment, + ) +end + +@testset "environment and output request matrix" begin + constant_scene = runtime_matrix_scene((T=12.0, duration=Hour(1))) + constant_sim = run!(constant_scene; steps=2, outputs=:all) + @test last.(outputs(constant_sim)[(:probe, ObjectId(:probe), :seen)]) == [12.0, 12.0] + + one_row_scene = runtime_matrix_scene([(T=13.0, duration=Hour(1))]) + one_row_sim = run!(one_row_scene) + @test only(model_objects(one_row_scene)).status.seen == 13.0 + @test_throws Exception run!(runtime_matrix_scene([(T=13.0, duration=Hour(1))]); steps=2) + + rows = [(T=14.0, duration=Hour(1)), (T=15.0, duration=Hour(1))] + selected_scene = runtime_matrix_scene(rows) + selected_sim = run!( + selected_scene; + steps=2, + outputs=OutputRequest(:Leaf, :seen; name=:selected_seen), + ) + selected = collect_outputs(selected_sim, :selected_seen; sink=nothing) + canonical = outputs(selected_sim)[(:probe, ObjectId(:probe), :seen)] + @test getproperty.(selected, :value) == last.(canonical) == [14.0, 15.0] + + no_outputs = run!( + runtime_matrix_scene(rows); + steps=2, + outputs=:none, + ) + @test isempty(outputs(no_outputs)) + @test isempty(collect_outputs(no_outputs; sink=nothing)) + + selector_scene = CompositeModel( + Object(:sun_leaf; scale=:Leaf, kind=:sun), + Object(:shade_leaf; scale=:Leaf, kind=:shade); + applications=( + ModelSpec(RuntimeMatrixProbeModel(); name=:probe) |> + AppliesTo(Many(scale=:Leaf)), + ), + environment=(T=16.0, duration=Hour(1)), + ) + selector_sim = run!( + selector_scene; + outputs=OutputRequest( + One(kind=:sun), + :seen; + name=:sun_seen, + application=:probe, + ), + ) + selector_rows = collect_outputs(selector_sim, :sun_seen; sink=nothing) + @test getproperty.(selector_rows, :object_id) == [:sun_leaf] + @test getproperty.(selector_rows, :value) == [16.0] + + contextual_sim = run!( + selector_scene; + outputs=OutputRequest( + One(Self()), + :seen; + name=:self_seen, + application=:probe, + context=:shade_leaf, + ), + ) + contextual_rows = collect_outputs(contextual_sim, :self_seen; sink=nothing) + @test getproperty.(contextual_rows, :object_id) == [:shade_leaf] + + continued_scene = runtime_matrix_scene([ + (T=17.0, duration=Hour(1)), + (T=18.0, duration=Hour(1)), + (T=19.0, duration=Hour(1)), + ]) + continued_sim = run!(continued_scene; outputs=:all) + continue!(continued_sim; steps=2) + @test last.(outputs(continued_sim)[ + (:probe, ObjectId(:probe), :seen) + ]) == [17.0, 18.0, 19.0] +end diff --git a/test/test-model-status-initialization.jl b/test/test-model-status-initialization.jl new file mode 100644 index 000000000..c2d366025 --- /dev/null +++ b/test/test-model-status-initialization.jl @@ -0,0 +1,46 @@ +using PlantSimEngine +using Test + +PlantSimEngine.@process "initialization_source" verbose = false +PlantSimEngine.@process "initialization_consumer" verbose = false + +struct InitializationSourceModel <: AbstractInitialization_SourceModel end +struct InitializationConsumerModel <: AbstractInitialization_ConsumerModel end + +PlantSimEngine.inputs_(::InitializationSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::InitializationSourceModel) = (signal=1,) +function PlantSimEngine.run!(::InitializationSourceModel, models, status, meteo, constants, extra) + status.signal += 1 +end + +PlantSimEngine.inputs_(::InitializationConsumerModel) = (signal=0, supplied=0) +PlantSimEngine.outputs_(::InitializationConsumerModel) = (observed=0,) +function PlantSimEngine.run!(::InitializationConsumerModel, models, status, meteo, constants, extra) + status.observed = status.signal + status.supplied +end + +@testset "generated and partial status" begin + model = CompositeModel( + Object(:object; scale=:Leaf, status=Status(supplied=40)); + applications=( + ModelSpec(InitializationSourceModel(); name=:source) |> AppliesTo(One(scale=:Leaf)), + ModelSpec(InitializationConsumerModel(); name=:consumer) |> AppliesTo(One(scale=:Leaf)), + ), + ) + compiled = Advanced.refresh_bindings!(model) + status = only(model_objects(model)).status + @test status.supplied == 40 + @test Set(propertynames(status)) == Set((:supplied, :signal, :observed)) + binding = only(row for row in compiled.input_bindings if row.input == :signal) + @test PlantSimEngine.refvalue(status, :signal) === input_carrier(binding) + report = explain_initialization(model) + @test only(row for row in report if row.variable == :supplied && row.role == :input).disposition == :supplied + @test only(row for row in report if row.variable == :signal && row.role == :input).disposition == :producer_bound + run!(model) + @test status.observed == 42 + + unresolved = CompositeModel(InitializationConsumerModel(); status=(supplied=1,)) + @test any(row -> row.variable == :signal && row.disposition == :unresolved, + explain_initialization(unresolved)) + @test_throws "Missing required composite-model/object input" run!(unresolved) +end diff --git a/test/test-model-temporal-reducers.jl b/test/test-model-temporal-reducers.jl new file mode 100644 index 000000000..4f6ec9cfd --- /dev/null +++ b/test/test-model-temporal-reducers.jl @@ -0,0 +1,136 @@ +using Dates +using PlantSimEngine +using Test + +PlantSimEngine.@process "temporal_reducer_source" verbose = false +PlantSimEngine.@process "temporal_reducer_one_arg" verbose = false +PlantSimEngine.@process "temporal_reducer_two_arg" verbose = false + +struct TemporalReducerSourceModel <: AbstractTemporal_Reducer_SourceModel end +struct TemporalReducerOneArgModel <: AbstractTemporal_Reducer_One_ArgModel end +struct TemporalReducerTwoArgModel <: AbstractTemporal_Reducer_Two_ArgModel end +PlantSimEngine.inputs_(::TemporalReducerSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::TemporalReducerSourceModel) = (signal=0.0,) +function PlantSimEngine.run!(::TemporalReducerSourceModel, models, status, meteo, constants, extra) + status.signal += 1 +end +PlantSimEngine.inputs_(::Union{TemporalReducerOneArgModel,TemporalReducerTwoArgModel}) = (reduced=0.0,) +PlantSimEngine.outputs_(::TemporalReducerOneArgModel) = (one_arg=0.0,) +PlantSimEngine.outputs_(::TemporalReducerTwoArgModel) = (two_arg=0.0,) +PlantSimEngine.run!(::TemporalReducerOneArgModel, models, status, meteo, constants, extra) = + (status.one_arg = status.reduced) +PlantSimEngine.run!(::TemporalReducerTwoArgModel, models, status, meteo, constants, extra) = + (status.two_arg = status.reduced) + +@testset "duration-aware and callable reducers" begin + @test RadiationEnergy()([100.0, 200.0], [1800.0, 3600.0]) ≈ 0.9 + @test RadiationEnergy()([big"100", big"200"], [1800.0, 3600.0]) isa BigFloat + + one_arg = values -> maximum(values) - minimum(values) + two_arg = (values, durations) -> sum(values .* durations) + model = CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec(TemporalReducerSourceModel(); name=:source) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(1)), + ModelSpec(TemporalReducerOneArgModel(); name=:one_arg) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs(:reduced => One( + scale=:Leaf, + application=:source, + var=:signal, + policy=Aggregate(one_arg), + window=Hour(3), + )) |> + TimeStep(Hour(3)), + ModelSpec(TemporalReducerTwoArgModel(); name=:two_arg) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs(:reduced => One( + scale=:Leaf, + application=:source, + var=:signal, + policy=Aggregate(two_arg), + window=Hour(3), + )) |> + TimeStep(Hour(3)), + ), + environment=(duration=Hour(1),), + ) + run!(model; steps=4) + status = only(model_objects(model)).status + @test status.one_arg == 2.0 + @test status.two_arg == 9 * 3600.0 + + invalid = (a, b, c) -> 0 + invalid_scene = CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec(TemporalReducerSourceModel(); name=:source) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(TemporalReducerOneArgModel(); name=:consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs(:reduced => One( + scale=:Leaf, + application=:source, + var=:signal, + policy=Aggregate(invalid), + )), + ), + ) + @test_throws "must accept values or values and durations" Advanced.refresh_bindings!(invalid_scene) +end + +@testset "coarse producer samples are weighted by held overlap" begin + observed_segments = Ref{Any}(nothing) + integral = function (values, durations) + if length(values) == 3 + observed_segments[] = (values=copy(values), durations=copy(durations)) + end + return sum(values .* durations) + end + weighted_mean = (values, durations) -> sum(values .* durations) / sum(durations) + + model = CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec(TemporalReducerSourceModel(); name=:source) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(2)), + ModelSpec(TemporalReducerTwoArgModel(); name=:integral) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs(:reduced => One( + scale=:Leaf, + application=:source, + var=:signal, + policy=Aggregate(integral), + window=Hour(4), + )) |> + TimeStep(Hour(4)), + ModelSpec(TemporalReducerOneArgModel(); name=:weighted_mean) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs(:reduced => One( + scale=:Leaf, + application=:source, + var=:signal, + policy=Aggregate(weighted_mean), + window=Hour(4), + )) |> + TimeStep(Hour(4)), + ), + environment=(duration=Hour(1),), + ) + + retention = only( + row for row in explain_output_retention(model) + if row.application_id == :source && row.variable == :signal + ) + @test retention.retention_steps == 5.0 + + run!(model; steps=5) + @test observed_segments[].values == [1.0, 2.0, 3.0] + @test observed_segments[].durations == [3600.0, 7200.0, 3600.0] + status = only(model_objects(model)).status + @test status.two_arg == 28_800.0 + @test status.one_arg == 2.0 +end diff --git a/test/test-model-time-validation.jl b/test/test-model-time-validation.jl new file mode 100644 index 000000000..710deec22 --- /dev/null +++ b/test/test-model-time-validation.jl @@ -0,0 +1,95 @@ +using Dates +using PlantSimEngine +using Test + +PlantSimEngine.@process "time_validation_counter" verbose = false +struct TimeValidationCounterModel <: AbstractTime_Validation_CounterModel end +PlantSimEngine.inputs_(::TimeValidationCounterModel) = NamedTuple() +PlantSimEngine.outputs_(::TimeValidationCounterModel) = (count=0,) +function PlantSimEngine.run!(::TimeValidationCounterModel, models, status, meteo, constants, extra) + status.count += 1 +end + +PlantSimEngine.@process "time_validation_override_hint" verbose = false +struct TimeValidationOverrideHintModel <: AbstractTime_Validation_Override_HintModel end +PlantSimEngine.inputs_(::TimeValidationOverrideHintModel) = NamedTuple() +PlantSimEngine.outputs_(::TimeValidationOverrideHintModel) = (count=0,) +PlantSimEngine.timestep_hint(::Type{<:TimeValidationOverrideHintModel}) = Day(1) +function PlantSimEngine.run!(::TimeValidationOverrideHintModel, models, status, meteo, constants, extra) + status.count += 1 +end + +function time_validation_scene(environment; cadence=nothing) + spec = ModelSpec(TimeValidationCounterModel(); name=:counter) |> + AppliesTo(One(scale=:Scene)) + isnothing(cadence) || (spec = spec |> TimeStep(cadence)) + return CompositeModel( + Object(:scene; scale=:Scene); + applications=(spec,), + environment=environment, + ) +end + +@testset "environment duration validation" begin + @test_throws "Missing required `duration` in meteorology row 1" Advanced.refresh_bindings!( + time_validation_scene([(T=20.0,)]) + ) + @test_throws "meteorology row 2" Advanced.refresh_bindings!( + time_validation_scene([(T=20.0, duration=Hour(1)), (T=21.0,)]) + ) + @test_throws "Invalid duration" Advanced.refresh_bindings!( + time_validation_scene([(duration="one hour",)]) + ) + @test_throws "positive period" Advanced.refresh_bindings!( + time_validation_scene([(duration=Hour(0),)]) + ) + @test_throws "positive period" Advanced.refresh_bindings!( + time_validation_scene([(duration=Hour(-1),)]) + ) + @test_throws "Inconsistent `duration` in meteorology row 2" Advanced.refresh_bindings!( + time_validation_scene([(duration=Hour(1),), (duration=Minute(30),)]) + ) + @test_throws "shorter than the simulation base step" Advanced.refresh_bindings!( + time_validation_scene([(duration=Hour(1),)]; cadence=Minute(30)) + ) + @test_throws "Unsupported non-fixed period" Advanced.refresh_bindings!( + time_validation_scene([(duration=Hour(1),)]; cadence=Month(1)) + ) +end + +@testset "CompoundPeriod base step" begin + base = Dates.CompoundPeriod(Minute(30)) + model = time_validation_scene([(duration=base,) for _ in 1:48]; cadence=Day(1)) + compiled = Advanced.refresh_bindings!(model) + schedule = only(explain_schedule(compiled)) + @test schedule.dt_steps == 48.0 + @test schedule.dt_seconds == 86_400.0 + simulation = run!(model; steps=1, outputs=:all) + @test only(model_objects(model)).status.count == 1 + @test length(outputs(simulation)[(:counter, ObjectId(:scene), :count)]) == 1 +end + +@testset "object overrides preserve timestep hints" begin + template = CompositeModelTemplate(( + ModelSpec(TimeValidationOverrideHintModel(); name=:counter) |> + AppliesTo(Many(scale=:Leaf)), + )) + instance = ObjectInstance( + :plant, + template; + root=Object(:plant_root; scale=:Plant), + objects=( + Object(:leaf_a; scale=:Leaf, parent=:plant_root), + Object(:leaf_b; scale=:Leaf, parent=:plant_root), + ), + object_overrides=( + Override( + object=:leaf_b, + application=:counter, + model=TimeValidationOverrideHintModel(), + ), + ), + ) + model = CompositeModel(instance; environment=(duration=Hour(1),)) + @test_throws "outside `timestep_hint.required=1 day`" Advanced.refresh_bindings!(model) +end diff --git a/test/test-mtg-dynamic.jl b/test/test-mtg-dynamic.jl deleted file mode 100644 index 28d7ef2d9..000000000 --- a/test/test-mtg-dynamic.jl +++ /dev/null @@ -1,197 +0,0 @@ -# using PlantSimEngine, DataFrames, MultiScaleTreeGraph -# using PlantSimEngine.Examples; -mtg = import_mtg_example(); - -# Example meteo: -meteo = Weather( - [ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65), - Atmosphere(T=25.0, Wind=0.5, Rh=0.8) -] -) - -mapping = ModelMapping( - :Scene => ToyDegreeDaysCumulModel(), - :Plant => ( - MultiScaleModel( - model=ToyLAIModel(), - mapped_variables=[ - :TT_cu => (:Scene => :TT_cu), - ], - ), - Beer(0.6), - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - ), - :Internode => ( - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => (:Scene => :TT),], - ), - MultiScaleModel( - model=ToyInternodeEmergence(TT_emergence=20.0), - mapped_variables=[:TT_cu => (:Scene => :TT_cu)], - ), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(carbon_biomass=1.0) - ), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content), :aPPFD => (:Plant => :aPPFD)], - ), - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => (:Scene => :TT),], - ), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - Status(carbon_biomass=1.0) - ), - :Soil => ( - ToySoilWaterModel(), - ), -) - -out_vars = Dict( - :Leaf => (:carbon_assimilation, :carbon_demand, :soil_water_content, :carbon_allocation), - :Internode => (:carbon_allocation, :TT_cu_emergence), - :Plant => (:carbon_allocation,), - :Soil => (:soil_water_content,), -) - -nsteps = PlantSimEngine.get_nsteps(meteo) -sim = PlantSimEngine.GraphSimulation(mtg, mapping, nsteps=nsteps, check=true, outputs=out_vars) -out = run!(sim,meteo) -#out = run!(mtg, mapping, meteo, tracked_outputs=out_vars, executor=SequentialEx()) - -@testset "MTG with dynamic growth" begin - st = sim.statuses - @test length(mtg) == 9 - @test length(st[:Scene]) == length(st[:Soil]) == length(st[:Plant]) == 1 - @test length(st[:Internode]) == length(st[:Leaf]) == 3 - @test st[:Internode][1].TT_cu_emergence == 0.0 - @test st[:Internode][end].TT_cu_emergence == 25.0 - - out_df_dict = convert_outputs(out, DataFrame) - @test collect(keys(out_df_dict)) |> sort == [:Internode, :Leaf, :Plant, :Soil] - @test out_df_dict[:Internode][:, :TT_cu_emergence] == [0.0, 0.0, 0.0, 0.0, 25.0] - @test out_df_dict[:Leaf][:, :carbon_demand] == [0.5, 0.5, 0.75, 0.75, -Inf] -end - -@testset "MTG with mixed daily/hourly clocks (2 leaves)" begin - mtg2 = Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) - plant2 = Node(mtg2, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - Node(mtg2, MultiScaleTreeGraph.NodeMTG("+", :Soil, 1, 1)) - internode2 = Node(plant2, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) - Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - - daily = ClockSpec(24.0, 1.0) - hourly = 1.0 - - mapping2 = Dict( - :Scene => ( - ModelSpec(ToyDegreeDaysCumulModel()) |> TimeStepModel(daily), - ), - :Plant => ( - ModelSpec(ToyLAIModel()) |> - MultiScaleModel([:TT_cu => (:Scene => :TT_cu)]) |> - TimeStepModel(daily), - ModelSpec(Beer(0.6)) |> TimeStepModel(hourly), - ModelSpec(ToyCAllocationModel()) |> - MultiScaleModel([ - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode], - ]) |> - InputBindings(; carbon_assimilation=(process=process(ToyAssimModel()), var=:carbon_assimilation, scale=:Leaf, policy=Integrate())) |> - TimeStepModel(daily), - ModelSpec(ToyPlantRmModel()) |> - MultiScaleModel([:Rm_organs => [:Leaf => :Rm, :Internode => :Rm]]) |> - TimeStepModel(daily), - ), - :Internode => ( - ModelSpec(ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0)) |> - MultiScaleModel([:TT => (:Scene => :TT)]) |> - TimeStepModel(daily), - # Keep emergence model in the stack (as in the dynamic test), but prevent growth in this scenario. - ModelSpec(ToyInternodeEmergence(TT_emergence=1.0e6)) |> - MultiScaleModel([:TT_cu => (:Scene => :TT_cu)]) |> - TimeStepModel(daily), - ModelSpec(ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004)) |> TimeStepModel(daily), - Status(carbon_biomass=1.0), - ), - :Leaf => ( - ModelSpec(ToyAssimModel()) |> - MultiScaleModel([:soil_water_content => (:Soil => :soil_water_content), :aPPFD => (:Plant => :aPPFD)]) |> - TimeStepModel(hourly), - ModelSpec(ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0)) |> - MultiScaleModel([:TT => (:Scene => :TT)]) |> - TimeStepModel(daily), - ModelSpec(ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025)) |> TimeStepModel(daily), - Status(carbon_biomass=1.0), - ), - :Soil => ( - ModelSpec(ToySoilWaterModel()) |> TimeStepModel(daily), - ), - ) - - out_vars2 = Dict( - :Leaf => (:carbon_assimilation, :aPPFD, :carbon_demand), - :Plant => (:LAI, :carbon_offer, :Rm), - :Scene => (:TT, :TT_cu), - :Soil => (:soil_water_content,), - :Internode => (:carbon_demand, :TT_cu_emergence), - ) - - nsteps2 = 48 - meteo2 = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f=300.0)], nsteps2)) - sim2 = PlantSimEngine.GraphSimulation(mtg2, mapping2, nsteps=nsteps2, check=true, outputs=out_vars2) - out2 = run!(sim2, meteo2, executor=SequentialEx()) - - st2 = status(sim2) - @test length(st2[:Scene]) == length(st2[:Soil]) == length(st2[:Plant]) == length(st2[:Internode]) == 1 - @test length(st2[:Leaf]) == 2 - - scope = ScopeId(:global, 1) - last_run = sim2.temporal_state.last_run - p_dd = process(ToyDegreeDaysCumulModel()) - p_lai = process(ToyLAIModel()) - p_beer = process(Beer(0.6)) - p_assim = process(ToyAssimModel()) - p_alloc = process(ToyCAllocationModel()) - p_cdemand = process(ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0)) - p_soil = process(ToySoilWaterModel()) - - @test last_run[ModelKey(scope, :Plant, p_beer)] == 48.0 - @test last_run[ModelKey(scope, :Leaf, p_assim)] == 48.0 - - @test last_run[ModelKey(scope, :Scene, p_dd)] == 25.0 - @test last_run[ModelKey(scope, :Plant, p_lai)] == 25.0 - @test last_run[ModelKey(scope, :Plant, p_alloc)] == 25.0 - @test last_run[ModelKey(scope, :Leaf, p_cdemand)] == 25.0 - @test last_run[ModelKey(scope, :Soil, p_soil)] == 25.0 - - @test st2[:Scene][1].TT_cu == 20.0 - last_daily_run = last_run[ModelKey(scope, :Plant, p_alloc)] - window_start = last_daily_run - daily.dt + 1.0 - out2_df = convert_outputs(out2, DataFrame) - integrated_assim_window = sum( - row.carbon_assimilation for row in eachrow(out2_df[:Leaf]) - if row.timestep >= window_start - 1e-8 && row.timestep <= last_daily_run + 1e-8 - ) - @test integrated_assim_window > 0.0 - @test isfinite(st2[:Plant][1].carbon_offer) - @test st2[:Plant][1].carbon_offer > -st2[:Plant][1].Rm - @test !isempty(out2[:Leaf]) -end diff --git a/test/test-mtg-multiscale-cyclic-dep.jl b/test/test-mtg-multiscale-cyclic-dep.jl deleted file mode 100644 index c9f9a4c88..000000000 --- a/test/test-mtg-multiscale-cyclic-dep.jl +++ /dev/null @@ -1,252 +0,0 @@ - -mtg = import_mtg_example() -# Example meteo: -meteo = Weather( - [ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f=300.0), - Atmosphere(T=25.0, Wind=0.5, Rh=0.8, Ri_PAR_f=500.0) -] -) - -out_vars = Dict( - :Plant => (:carbon_allocation,), - :Leaf => (:carbon_demand, :carbon_allocation, :carbon_biomass), - :Internode => (:carbon_demand, :carbon_allocation, :carbon_biomass), -) - -@testset "Cyclic dependency -> error" begin - mapping_cyclic = ModelMapping( - :Plant => ( - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - Status(total_surface=0.001, aPPFD=1300.0, soil_water_content=0.6), - ), - :Internode => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(TT=10.0, carbon_biomass=1.0), - ), - :Leaf => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - ToyCBiomassModel(1.2), - Status(TT=10.0), - ) - ) - # In this mapping, we have a cyclic dependency with the carbon allocation and the carbon biomass. The carbon biomass depends on the carbon allocation, which itself depends on the - # plant Rm, which depends on the Rm organs, which depends on the carbon biomass. This is a cyclic dependency that we need to break somewhere. - - @test_throws "Cyclic dependency detected in the graph. Cycle:" dep(mapping_cyclic) - - soft_dep_graphs_roots, hard_dep_dict = PlantSimEngine.hard_dependencies(mapping_cyclic) - - mapped_vars = PlantSimEngine.mapped_variables(mapping_cyclic, soft_dep_graphs_roots, verbose=false) - reverse_mapping_cyclic = PlantSimEngine.reverse_mapping(mapped_vars, all=false) - - dep_graph = PlantSimEngine.soft_dependencies_multiscale(soft_dep_graphs_roots, reverse_mapping_cyclic, hard_dep_dict) - iscyclic, cycle_vec = PlantSimEngine.is_graph_cyclic(dep_graph; warn=false) - - @test iscyclic - @test cycle_vec == [ToyPlantRmModel() => :Plant, ToyMaintenanceRespirationModel{Float64}(2.1, 0.06, 25.0, 1.0, 0.025) => :Leaf, ToyCBiomassModel{Float64}(1.2) => :Leaf, ToyCAllocationModel() => :Plant, ToyPlantRmModel() => :Plant] -end - - -@testset "Cyclic dependency -> fixed with `PreviousTimeStep`" begin - mapping_nocyclic = ModelMapping( - :Plant => ( - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - Status(total_surface=0.001, aPPFD=1300.0, soil_water_content=0.6, carbon_assimilation=5.0), - ), - :Internode => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - MultiScaleModel( - model=ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - mapped_variables=[PreviousTimeStep(:carbon_biomass),], #! this is where we break the cyclic dependency (first break) - ), - Status(TT=10.0, carbon_biomass=1.0), - ), - :Leaf => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - MultiScaleModel( - model=ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - mapped_variables=[PreviousTimeStep(:carbon_biomass),], #! this is where we break the cyclic dependency (second break) - ), - ToyCBiomassModel(1.2), - Status(TT=10.0), - ) - ) - # In this mapping, we have a cyclic dependency with the carbon allocation and the carbon biomass like the test above, but we - # break the cyclic dependency by using the value of the biomass from the previous time-step instead of taking the current computation. - - @test_nowarn dep(mapping_nocyclic) - - soft_dep_graphs_roots, hard_dep_dict = PlantSimEngine.hard_dependencies(mapping_nocyclic) - # soft_dep_graphs_roots.roots[:Leaf].inputs - mapped_vars = PlantSimEngine.mapped_variables(mapping_nocyclic, soft_dep_graphs_roots, verbose=false) - reverse_mapping_nocyclic = PlantSimEngine.reverse_mapping(mapped_vars, all=false) - - dep_graph = PlantSimEngine.soft_dependencies_multiscale(soft_dep_graphs_roots, reverse_mapping_nocyclic, hard_dep_dict) - iscyclic, cycle_vec = PlantSimEngine.is_graph_cyclic(dep_graph; warn=false) - - @test !iscyclic - @test length(cycle_vec) == 7 - @test to_initialize(mapping_nocyclic) == Dict() - - - #out = @test_nowarn run!(mtg, mapping_nocyclic, meteo, tracked_outputs=out_vars, executor=SequentialEx()) - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg, mapping, nsteps=nsteps, check=true, outputs=out_vars) - out = @test_nowarn run!(sim,meteo) - st = status(sim) - - st[:Leaf][1].carbon_biomass = 2.0 - @test st[:Leaf][2].carbon_biomass != 2.0 -end - -@testset "Mutiscale simulation -> cyclic dependency" begin - mapping = ModelMapping( - :Scene => ( - ToyDegreeDaysCumulModel(), - MultiScaleModel( - model=ToyLAIfromLeafAreaModel(1.0), - mapped_variables=[ - :plant_surfaces => [:Plant => :surface], - ], - ), - Beer(0.6), - ), - :Plant => ( - MultiScaleModel( - model=ToyPlantLeafSurfaceModel(), - mapped_variables=[PreviousTimeStep(:leaf_surfaces) => [:Leaf => :surface],], - #! We use PreviousTimeStep to break the cyclic dependency between the LAI and the leaf surface - # that is computed as one of the latest sub-models. Now the LAI used for light interception - # will be the one from the previous time-step, and at the end of the time-step we will update - # the leaf surface. - ), - MultiScaleModel( - model=ToyLightPartitioningModel(), - mapped_variables=[ - :aPPFD_larger_scale => (:Scene => :aPPFD), - :total_surface => (:Scene => :total_surface) - ], - ), - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[ - :soil_water_content => (:Soil => :soil_water_content), - ], - ), - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - :carbon_demand => [:Leaf, :Internode], - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - ), - :Internode => ( - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => (:Scene => :TT),], - ), - MultiScaleModel( - model=ToyInternodeEmergence(TT_emergence=20.0), - mapped_variables=[:TT_cu => (:Scene => :TT_cu)], - ), - MultiScaleModel( - model=ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - mapped_variables=[PreviousTimeStep(:carbon_biomass),], #! this is where we break the cyclic dependency (first break) - ), - ToyCBiomassModel(1.1), - Status(carbon_biomass=0.0) - ), - :Leaf => ( - MultiScaleModel( - model=ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - mapped_variables=[:TT => (:Scene => :TT),], - ), - MultiScaleModel( - model=ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - mapped_variables=[PreviousTimeStep(:carbon_biomass),], #! this is where we break the cyclic dependency (first break) - ), - ToyCBiomassModel(1.2), - ToyLeafSurfaceModel(0.1), - Status(carbon_biomass=0.0, surface=0.001,) - ), - :Soil => ( - ToySoilWaterModel(), - ), - ) - # In this mapping, we have a cyclic dependency. The leaf surface is computed using the leaf biomass, which is computed using the carbon allocation, which itself depends on the - # light interception, which is computed using the LAI at scene scale, itself coming from the plant total leaf surface, computed as the sum of all leaves surfaces. - # This is a cyclic dependency that we need to break somewhere. We can break it by using the value of the leaf surface from the previous time-step, which is a common practice in - # plant models. This is done by flagging the leaf surface as a PreviousTimeStep variable in the mapping of the leaf node. - - out_vars = Dict( - :Scene => (:TT_cu, :LAI, :plant_surfaces, :aPPFD), - :Plant => (:carbon_assimilation, :carbon_allocation, :aPPFD, :soil_water_content, :leaf_surfaces, :surface, :total_surface), - :Leaf => (:carbon_demand, :carbon_allocation, :carbon_biomass), - :Internode => (:carbon_demand, :carbon_allocation, :TT_cu_emergence, :carbon_biomass), - :Soil => (:soil_water_content,), - ) - mtg = import_mtg_example() - meteo = Weather( - [ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f=300.0), - Atmosphere(T=25.0, Wind=0.5, Rh=0.8, Ri_PAR_f=500.0) - ] - ) - - d = @test_nowarn dep(mapping) - @test to_initialize(mapping) == Dict() - #out = @test_nowarn run!(mtg, mapping, meteo, tracked_outputs=out_vars, executor=SequentialEx()) - - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg, mapping, nsteps=nsteps, check=true, outputs=out_vars) - out = run!(sim,meteo) - - # To update the reference: - ref_path = joinpath(pkgdir(PlantSimEngine), "test/references/ref_output_simulation.csv") - # CSV.write(ref_path, sort(convert_outputs(out, DataFrame, no_value=missing), [:timestep, :node]), transform=(col, val) -> something(val, missing)) - ref_df = CSV.read(ref_path, DataFrame) - - @test sort(unique(ref_df.organ)) == sort(string.(collect(keys(out)))) - - out_df_dict = convert_outputs(out, DataFrame, no_value=missing) - - for organ in keys(out) - reduced_ref_df = ref_df[(ref_df.organ .== string(organ)), Not(:organ)] - reduced_ref_df_no_missing = reduced_ref_df[:, any.(!ismissing, eachcol(reduced_ref_df))] - sorted_reduced_ref_df_no_missing = DataFrames.select(reduced_ref_df_no_missing, sort(propertynames(reduced_ref_df_no_missing))) - sorted_out_df_dict_organ = DataFrames.select(out_df_dict[organ], sort(propertynames(out_df_dict[organ]))) - @test size(sorted_reduced_ref_df_no_missing) == size(sorted_out_df_dict_organ) - @test propertynames(sorted_reduced_ref_df_no_missing) == propertynames(sorted_out_df_dict_organ) - for c in intersect([:node, :timestep], propertynames(sorted_out_df_dict_organ)) - @test sorted_reduced_ref_df_no_missing[:, c] == sorted_out_df_dict_organ[:, c] - end - end -end diff --git a/test/test-mtg-multiscale.jl b/test/test-mtg-multiscale.jl deleted file mode 100644 index 922700f93..000000000 --- a/test/test-mtg-multiscale.jl +++ /dev/null @@ -1,728 +0,0 @@ -# Example meteo: -meteo = Weather( - [ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65), - Atmosphere(T=25.0, Wind=0.5, Rh=0.8) -] -) - -@testset "MTG initialisation" begin - simple_mtg = Node(MultiScaleTreeGraph.NodeMTG("/", :Plant, 1, 1)) - internode = Node(simple_mtg, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) - leaf = Node(simple_mtg, MultiScaleTreeGraph.NodeMTG("<", :Leaf, 1, 2)) - var1 = 15.0 - var2 = 0.3 - leaf[:var2] = var2 - - mapping = ModelMapping( - :Leaf => ( - Process1Model(1.0), - Process2Model(), - Process3Model(), - Status(var1=var1,) - ) - ) - - @test descendants(simple_mtg, :var1) == [nothing, nothing] - @test descendants(simple_mtg, :var2) == [nothing, var2] - - @test to_initialize(mapping) == Dict(:Leaf => [:var2]) # NB: :var1 is initialised in the status - @test to_initialize(mapping, simple_mtg) == Dict() -end - -# A mapping that actually works (same as before but with the init for TT): -mapping_1 = ModelMapping( - :Plant => ( - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - # inputs - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - # outputs - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - ), - :Internode => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(TT=10.0) - ), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], - # Notice we provide :Soil, not [:Soil], so a single value is expected here - ), - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - Status(aPPFD=1300.0, TT=10.0), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - ), - :Soil => ( - ToySoilWaterModel(), - ), -) - -# Example MTG: -mtg = begin - scene = Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) - soil = Node(scene, MultiScaleTreeGraph.NodeMTG("/", :Soil, 1, 1)) - plant = Node(scene, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - internode1 = Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) - leaf1 = Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - internode2 = Node(internode1, MultiScaleTreeGraph.NodeMTG("<", :Internode, 1, 2)) - leaf2 = Node(internode2, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - scene -end - -# Another MTG with initialisation values for biomass: -mtg_init = deepcopy(mtg) -MultiScaleTreeGraph.transform!(mtg_init, (x -> 1.0) => :carbon_biomass, symbol=[:Internode, :Leaf]) - -@testset "Multiscale dependency graph" begin - d = dep(mapping_1) - c_allocation_plant_scale = d.roots[:Leaf=>:carbon_demand].children[1].outputs - carbon_allocation_vars = last(c_allocation_plant_scale[1]) - @test carbon_allocation_vars[:carbon_offer] == -Inf - @test isa(carbon_allocation_vars[:carbon_allocation], PlantSimEngine.MappedVar) - @test PlantSimEngine.mapped_organ(carbon_allocation_vars[:carbon_allocation]) == [:Leaf, :Internode] - @test PlantSimEngine.mapped_default(carbon_allocation_vars[:carbon_allocation]) == PlantSimEngine.outputs_(ToyCAllocationModel()).carbon_allocation - @test PlantSimEngine.mapped_variable(carbon_allocation_vars[:carbon_allocation]) == :carbon_allocation - @test PlantSimEngine.source_variable(carbon_allocation_vars[:carbon_allocation]) == [:carbon_allocation, :carbon_allocation] - - # Testing inputs and outputs of the nodes: - @test d.roots[:Soil=>:soil_water].inputs == [:soil_water => NamedTuple()] - # Testing if the status given by the user is used to set the default values of the variables in the nodes: - @test last(d.roots[:Soil=>:soil_water].children[1].inputs[1]).aPPFD == 1300.0 - # Testing that the outputs that are not multiscale are simply initialised with the default values from the models: - @test d.roots[:Internode=>:carbon_demand].outputs[1] |> last |> first == -Inf - # Testing that the intputs that are not multiscale are initialised with the default values from the models, as an `UninitializedVar`: - @test d.roots[:Internode=>:maintenance_respiration].inputs[1] |> last |> first == PlantSimEngine.UninitializedVar(:carbon_biomass, 0.0) -end - -@testset "inputs and outputs of a mapping" begin - ins = inputs(mapping_1) - - @test collect(keys(ins)) == collect(keys(mapping_1)) - @test ins[:Soil] == (soil_water=(),) - @test ins[:Leaf] == (carbon_assimilation=(:aPPFD, :soil_water_content), carbon_demand=(:TT,), maintenance_respiration=(:carbon_biomass,)) - - outs = outputs(mapping_1) - @test collect(keys(outs)) == collect(keys(mapping_1)) - @test outs[:Soil] == (soil_water=(:soil_water_content,),) - @test outs[:Leaf] == (carbon_assimilation=(:carbon_assimilation,), carbon_demand=(:carbon_demand,), maintenance_respiration=(:Rm,)) - @test outs[:Plant] == (carbon_allocation=(:carbon_offer, :carbon_allocation), maintenance_respiration=(:Rm,)) - - vars = variables(mapping_1) - @test collect(keys(vars)) == collect(keys(mapping_1)) - @test keys(vars[:Soil]) == keys(outs[:Soil]) - @test keys(values(vars[:Soil])[1]) == values(outs[:Soil])[1] - @test vars[:Plant] == (carbon_allocation=(carbon_assimilation=[-Inf], Rm=-Inf, carbon_demand=[-Inf], carbon_offer=-Inf, carbon_allocation=[-Inf]), maintenance_respiration=(Rm_organs=[-Inf], Rm=-Inf),) - @test vars[:Leaf] == (carbon_assimilation=(aPPFD=-Inf, soil_water_content=-Inf, carbon_assimilation=-Inf), carbon_demand=(TT=-Inf, carbon_demand=-Inf), maintenance_respiration=(carbon_biomass=0.0, Rm=-Inf)) -end - -@testset "Status initialisation" begin - # Samuel : internal function, suppressing REPL display errors - hard_dep_graph = first(PlantSimEngine.hard_dependencies(mapping_1; verbose=false)); - @test_throws "Variable `carbon_biomass` is not computed by any model, not initialised by the user in the status, and not found in the MTG at scale Internode (checked for MTG node 4)." PlantSimEngine.init_statuses(mtg, mapping_1, hard_dep_graph) - organs_statuses, others = PlantSimEngine.init_statuses(mtg_init, mapping_1, hard_dep_graph) - - @test Set(keys(organs_statuses)) == Set([:Soil, :Internode, :Plant, :Leaf]) - # Check that the soil_water_content is linked between the soil and the leaves: - @test length(organs_statuses[:Soil]) == length(organs_statuses[:Plant]) == 1 - @test length(organs_statuses[:Leaf]) == length(organs_statuses[:Internode]) == 2 - @test organs_statuses[:Soil][1][:soil_water_content] === -Inf - @test organs_statuses[:Leaf][1][:soil_water_content] === -Inf - @test organs_statuses[:Leaf][2][:soil_water_content] === -Inf - @test organs_statuses[:Leaf][2][:soil_water_content] === organs_statuses[:Soil][1][:soil_water_content] - - organs_statuses[:Soil][1][:soil_water_content] = 1.0 - @test organs_statuses[:Leaf][1][:soil_water_content][] == 1.0 - - @test organs_statuses[:Plant][1][:carbon_assimilation] == PlantSimEngine.RefVector{Float64}([Ref(-Inf), Ref(-Inf)]) - @test organs_statuses[:Plant][1][:carbon_allocation] == PlantSimEngine.RefVector{Float64}([Ref(-Inf), Ref(-Inf), Ref(-Inf), Ref(-Inf)]) - @test organs_statuses[:Internode][1][:carbon_allocation] == -Inf - @test organs_statuses[:Leaf][1][:carbon_demand] == -Inf - - # Testing with a different type: - organs_statuses, others = PlantSimEngine.init_statuses(mtg_init, mapping_1, hard_dep_graph, type_promotion=Dict(Float64 => Float32, Vector{Float64} => Vector{Float32})) - - @test isa(organs_statuses[:Plant][1][:carbon_assimilation], PlantSimEngine.RefVector{Float32}) - @test isa(organs_statuses[:Plant][1][:carbon_allocation], PlantSimEngine.RefVector{Float32}) - @test isa(organs_statuses[:Internode][1][:carbon_allocation], Float32) - @test isa(organs_statuses[:Leaf][1][:carbon_demand], Float32) - @test isa(organs_statuses[:Soil][1][:soil_water_content], Float32) - - mapping_promoted = ModelMapping(Dict(mapping_1); type_promotion=Dict(Float64 => Float32, Vector{Float64} => Vector{Float32})) - sim_promoted = PlantSimEngine.GraphSimulation( - mtg_init, - mapping_promoted; - nsteps=1, - outputs=Dict(:Soil => (:soil_water_content,)), - check=true, - ) - - @test sim_promoted.statuses[:Soil][1][:soil_water_content] isa Float32 - @test sim_promoted.outputs[:Soil][1][:soil_water_content] isa Float32 -end - - -@testset "Multiscale initialisations and outputs" begin - outs = Dict( - :Flowers => (:carbon_assimilation, :carbon_demand), # There are no flowers in this MTG - :Leaf => (:carbon_assimilation, :carbon_demand, :non_existing_variable), # :non_existing_variable is not computed by any model - :Soil => (:soil_water_content,), - ) - - type_promotion = nothing - nsteps = 2 - dependency_graph = dep(mapping_1) - hard_dep_graph = first(PlantSimEngine.hard_dependencies(mapping_1; verbose=false)) - organs_statuses, others = PlantSimEngine.init_statuses(mtg_init, mapping_1, hard_dep_graph; type_promotion=type_promotion) - - @test Set(keys(organs_statuses)) == Set([:Soil, :Internode, :Plant, :Leaf]) - @test collect(keys(organs_statuses[:Soil][1])) == [:node, :soil_water_content] - @test collect(keys(organs_statuses[:Leaf][1])) == [:carbon_allocation, :carbon_assimilation, :TT, :carbon_biomass, :aPPFD, :node, :Rm, :soil_water_content, :carbon_demand] - @test collect(keys(organs_statuses[:Plant][1])) == [:Rm_organs, :carbon_allocation, :carbon_assimilation, :node, :carbon_offer, :Rm, :carbon_demand] - @test organs_statuses[:Soil][1][:soil_water_content][] === -Inf - @test organs_statuses[:Leaf][1][:carbon_allocation] === -Inf - @test organs_statuses[:Leaf][1][:TT] === 10.0 - @test typeof(organs_statuses[:Plant][1][:carbon_allocation]) === PlantSimEngine.RefVector{Float64} - - @test PlantSimEngine.reverse_mapping(mapping_1, all=true) == Dict{Symbol,Dict{Symbol,Dict{Symbol,Any}}}( - :Soil => Dict(:Leaf => Dict(:soil_water_content => :soil_water_content)), - :Internode => Dict(:Plant => Dict(:carbon_allocation => :carbon_allocation, :Rm => :Rm_organs, :carbon_demand => :carbon_demand)), - :Leaf => Dict(:Plant => Dict(:carbon_allocation => :carbon_allocation, :carbon_assimilation => :carbon_assimilation, :Rm => :Rm_organs, :carbon_demand => :carbon_demand)) - ) - - @test PlantSimEngine.reverse_mapping(mapping_1, all=false) == Dict{Symbol,Dict{Symbol,Dict{Symbol,Any}}}( - :Internode => Dict(:Plant => Dict(:carbon_allocation => :carbon_allocation, :Rm => :Rm_organs, :carbon_demand => :carbon_demand)), - :Leaf => Dict(:Plant => Dict(:carbon_allocation => :carbon_allocation, :carbon_assimilation => :carbon_assimilation, :Rm => :Rm_organs, :carbon_demand => :carbon_demand)) - ) - - @test PlantSimEngine.reverse_mapping(filter(x -> x.first == :Soil, mapping_1)) == Dict{Symbol,Dict{Symbol,Dict{Symbol,Any}}}() - @test PlantSimEngine.to_initialize(mapping_1, mtg) == Dict(:Internode => [:carbon_biomass], :Leaf => [:carbon_biomass]) - @test PlantSimEngine.to_initialize(mapping_1, mtg_init) == Dict{Symbol,Symbol}() - - statuses, status_templates, reverse_multiscale_mapping, vars_need_init = PlantSimEngine.init_statuses(mtg_init, mapping_1, hard_dep_graph) - @test Set(keys(statuses)) == Set([:Soil, :Internode, :Plant, :Leaf]) - - @test length(statuses[:Internode]) == length(statuses[:Leaf]) == 2 - @test length(statuses[:Soil]) == length(statuses[:Plant]) == 1 - - e_1 = "You requested outputs for organs Soil, Flowers, Leaf, but organs Flowers have no models." - e_2 = "You requested outputs for variables A, carbon_demand, non_existing_variable, but variables non_existing_variable have no models." - - # If check is true, this should return an error (some outputs are not computed): - if VERSION < v"1.8" # We test differently depending on the julia version because the format of the error message changed - @test_throws ErrorException PlantSimEngine.pre_allocate_outputs(statuses, status_templates, reverse_multiscale_mapping, vars_need_init, outs, nsteps) - else - @test_throws e_1 PlantSimEngine.pre_allocate_outputs(statuses, status_templates, reverse_multiscale_mapping, vars_need_init, outs, nsteps) - end - - outs_ = @test_logs (:info, "You requested outputs for organs Soil, Flowers, Leaf, but organs Flowers have no models.") (:info, "You requested outputs for variable non_existing_variable in organ Leaf, but it has no model.") PlantSimEngine.pre_allocate_outputs(statuses, status_templates, reverse_multiscale_mapping, vars_need_init, outs, nsteps, check=false) - - @test outs_ == Dict( - :Soil => [Status(timestep = 1, node = MultiScaleTreeGraph.Node(NodeMTG("/", :Uninitialized, 0, 0)), soil_water_content = -Inf), - Status(timestep = 1, node = MultiScaleTreeGraph.Node(NodeMTG("/", :Uninitialized, 0, 0),), soil_water_content = -Inf)], - :Leaf => [Status(timestep = 1, node = MultiScaleTreeGraph.Node(NodeMTG("/", :Uninitialized, 0, 0),), carbon_assimilation = -Inf, carbon_demand = -Inf), - Status(timestep = 1, node = MultiScaleTreeGraph.Node(NodeMTG("/", :Uninitialized, 0, 0),), carbon_assimilation = -Inf, carbon_demand = -Inf)] - ) -end - -# Testing the mappings: -@testset "Mapping: missing initialisation" begin - mapping = ModelMapping( - :Plant => - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - # inputs - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - # outputs - :carbon_allocation => [:Leaf, :Internode] - ], - ), - :Internode => ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], - # Notice we provide :Soil, not [:Soil], so a single value is expected here - ), - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - Status(aPPFD=1300.0, TT=10.0), - ), - :Soil => ( - ToySoilWaterModel(), - ), - ) - - to_init = @test_nowarn to_initialize(mapping) - - @test to_init[:Internode] == [:TT] - - mapped_vars = PlantSimEngine.mapped_variables(mapping) - @test collect(keys(mapped_vars[:Plant])) == [:carbon_allocation, :carbon_assimilation, :carbon_offer, :Rm, :carbon_demand] - @test [PlantSimEngine.mapped_default(i) for i in values(mapped_vars[:Plant])] == [-Inf, -Inf, -Inf, PlantSimEngine.UninitializedVar{Float64}(:Rm, -Inf), -Inf] - @test collect(keys(mapped_vars[:Leaf])) == [:carbon_allocation, :carbon_assimilation, :TT, :aPPFD, :soil_water_content, :carbon_demand] - @test [PlantSimEngine.mapped_default(i) for i in values(mapped_vars[:Leaf])] == [-Inf, -Inf, 10.0, 1300.0, -Inf, -Inf] - @test collect(keys(mapped_vars[:Soil])) == [:soil_water_content] - @test [PlantSimEngine.mapped_default(i) for i in values(mapped_vars[:Soil])] == [-Inf] -end - -@testset "Mapping: missing organ in mapping (Soil)" begin - @test_throws "missing scale `Soil`" ModelMapping( - :Plant => - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - # inputs - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - # outputs - :carbon_allocation => [:Leaf, :Internode] - ], - ), - :Internode => ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], - # Notice we provide :Soil, not [:Soil], so a single value is expected here - ), - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - Status(aPPFD=1300.0, TT=10.0), - ) - ) -end - -mtg_var = let - scene = Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) - soil = Node(scene, MultiScaleTreeGraph.NodeMTG("/", :Soil, 1, 1)) - plant = Node(scene, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - internode1 = Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) - leaf1 = Node(internode1, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - scene -end - -@testset "Mapping: missing model at other scale (soil_water_content) + missing init + var1 from MTG" begin - @test_throws "not available at scale `Soil`" ModelMapping( - :Plant => - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - # inputs - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - # outputs - :carbon_allocation => [:Leaf, :Internode] - ], - ), - :Internode => ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], - # Notice we provide :Soil, not [:Soil], so a single value is expected here - ), - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - Status(aPPFD=1300.0, TT=10.0), - ), - :Soil => ( - Process1Model(1.0), - ), - ) -end - -@testset "Mapping: missing init + var1 from MTG" begin - mapping = ModelMapping( - :Plant => - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - # inputs - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - # outputs - :carbon_allocation => [:Leaf, :Internode] - ], - ), - :Internode => ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :var3),], - # Notice we provide :Soil, not [:Soil], so a single value is expected here - ), - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - Status(aPPFD=1300.0, TT=10.0), - ), - :Soil => ( - Process1Model(1.0), - ), - ) - - to_init = to_initialize(mapping, mtg_var) - - @test to_init[:Soil] == [:var1, :var2]# var1 would be here if not present in the MTG - - soil_node = mtg_var[1] - soil_node[:var1] = 1.0 - to_init = to_initialize(mapping, mtg_var) - @test to_init[:Soil] == [:var2]# var1 would be here if not present in the MTG - @test !haskey(to_init, :Leaf) - @test to_init[:Internode] == [:TT] -end -# Testing with a simple mapping (just the soil model, no multiscale mapping): -@testset "run! on MTG: simple mapping" begin - #out = @test_nowarn run!(mtg, Dict(:Soil => (ToySoilWaterModel(),)), meteo) - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg, ModelMapping(:Soil => (ToySoilWaterModel(),)), nsteps=nsteps, check=true, outputs=nothing) - out = run!(sim,meteo) - - @test sim.statuses[:Soil][1].node == soil - @test sim.models == Dict(:Soil => (soil_water=ToySoilWaterModel(sim.models[:Soil].soil_water.values),)) - @test sim.models[:Soil].soil_water.values == [0.5] - @test length(sim.dependency_graph.roots) == 1 - @test collect(keys(sim.dependency_graph.roots))[1] == Pair(:Soil, :soil_water) - @test sim.graph == mtg - - leaf_mapping = ModelMapping(:Leaf => (ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), Status(TT=10.0))) - - #out = run!(mtg, leaf_mapping, meteo) - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg, leaf_mapping, nsteps=nsteps, check=true, outputs=nothing) - out = run!(sim,meteo) - - @test collect(keys(sim.statuses)) == [:Leaf] - @test length(sim.statuses[:Leaf]) == 2 - @test sim.statuses[:Leaf][1].TT == 10.0 # As initialized in the mapping - @test sim.statuses[:Leaf][1].carbon_demand == 0.5 - - @test sim.statuses[:Leaf][1].node == leaf1 - @test sim.statuses[:Leaf][2].node == leaf2 -end - -# A mapping with all different types of mapping (single, multi-scale, model as is, or tuple of): -@testset "run! on MTG with complete mapping (missing init)" begin - mapping_all = ModelMapping( - :Plant => - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - # inputs - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - # outputs - :carbon_allocation => [:Leaf, :Internode] - ], - ), - :Internode => ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], - # Notice we provide :Soil, not [:Soil], so a single value is expected here - ), - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - Status(aPPFD=1300.0, TT=10.0), - ), - :Soil => ( - ToySoilWaterModel(), - ), - ) - # The mapping above should throw an error because TT is not initialized for the Internode: - if VERSION < v"1.8" # We test differently depending on the julia version because the format of the error message changed - - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg, mapping_all, nsteps=nsteps, check=true, outputs=nothing) - @test_throws ErrorException out = run!(sim,meteo) - else - @test_throws "Variable `Rm` is not computed by any model, not initialised by the user in the status, and not found in the MTG at scale Plant (checked for MTG node 3)." run!(mtg, mapping_all, meteo) - end - - # It should work if we don't check the mapping though: - #out = @test_nowarn run!(mtg, mapping_all, meteo, check=false) - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg, mapping_all, nsteps=nsteps, check=false, outputs=nothing) - out = run!(sim,meteo) - # Note that the outputs are garbage because the TT is not initialized. - - @test sim.models == Dict{Symbol,NamedTuple}( - :Soil => (soil_water=ToySoilWaterModel(sim.models[:Soil].soil_water.values),), - :Internode => (carbon_demand=ToyCDemandModel{Float64}(10.0, 200.0),), - :Plant => (carbon_allocation=ToyCAllocationModel(),), - :Leaf => (carbon_assimilation=ToyAssimModel{Float64}(0.2), carbon_demand=ToyCDemandModel{Float64}(10.0, 200.0)) - ) - @test sim.models[:Soil].soil_water.values == [0.5] - @test length(sim.dependency_graph.roots) == 3 # 3 because the plant is not a root (its model has dependencies) - @test sim.statuses[:Internode][1].TT === -Inf - @test sim.statuses[:Internode][1].carbon_demand === -Inf - - st_leaf1 = sim.statuses[:Leaf][1] - @test st_leaf1.TT == 10.0 - @test st_leaf1.carbon_demand == 0.5 - # This one depends on the soil, which is random, so we test using the computation directly: - @test st_leaf1.carbon_assimilation == st_leaf1.aPPFD * sim.models[:Leaf].carbon_assimilation.LUE * st_leaf1.soil_water_content - @test st_leaf1.carbon_allocation == 0.0 -end - -@testset "run! on MTG with complete mapping (with init)" begin - - #out = @test_nowarn run!(mtg_init, mapping_1, meteo, executor=SequentialEx()) - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg_init, mapping_1, nsteps=nsteps, check=false, outputs=nothing) - out = run!(sim,meteo) - - @test typeof(sim.statuses) == Dict{Symbol,Vector{Status}} - @test length(sim.statuses[:Plant]) == 1 - @test length(sim.statuses[:Leaf]) == 2 - @test length(sim.statuses[:Internode]) == 2 - @test length(sim.statuses[:Soil]) == 1 - @test sim.statuses[:Soil][1].node == get_node(mtg_init, 2) - @test sim.statuses[:Soil][1].soil_water_content !== -Inf - - # Testing that we get the link between the node and its status: - @test sim.statuses[:Soil][1] == get_node(mtg_init, 2).plantsimengine_status - # Testing if the value in the status of the leaves is the same as the one in the status of the soil: - @test sim.statuses[:Soil][1].soil_water_content === sim.statuses[:Leaf][1].soil_water_content - @test sim.statuses[:Soil][1].soil_water_content === sim.statuses[:Leaf][2].soil_water_content - - leaf1_status = sim.statuses[:Leaf][1] - - # This is the model that computes the assimilation (testing manually that we get the right result here): - @test leaf1_status.carbon_assimilation == leaf1_status.aPPFD * sim.models[:Leaf].carbon_assimilation.LUE * leaf1_status.soil_water_content - - @test sim.statuses[:Plant][1].carbon_demand[[1, 3]] == [i.carbon_demand for i in sim.statuses[:Internode]] - @test sim.statuses[:Plant][1].carbon_demand[[2, 4]] == [i.carbon_demand for i in sim.statuses[:Leaf]] - - # Testing the reference directly: - ref_values_cdemand = getfield(sim.statuses[:Plant][1].carbon_demand, :v) - - for (j, i) in enumerate([1, 3]) - @test ref_values_cdemand[i] === PlantSimEngine.refvalue(sim.statuses[:Internode][j], :carbon_demand) - end - - for (j, i) in enumerate([2, 4]) - @test ref_values_cdemand[i] === PlantSimEngine.refvalue(sim.statuses[:Leaf][j], :carbon_demand) - end - - # Testing that carbon allocation in Leaf and Internode was added as a variable from the model at the Plant scale: - - @test hasproperty(sim.statuses[:Internode][1], :carbon_allocation) - @test hasproperty(sim.statuses[:Leaf][1], :carbon_allocation) - - @test sim.statuses[:Internode][1].carbon_allocation == 0.5 - @test sim.statuses[:Leaf][1].carbon_allocation == 0.5 - - - # Testing that we get the link between the node and its status: - @test sim.statuses[:Leaf][2] == get_node(mtg_init, 7).plantsimengine_status - - # Testing the reference directly: - ref_values_callocation = getfield(sim.statuses[:Plant][1].carbon_allocation, :v) - - for (j, i) in enumerate([1, 3]) - @test ref_values_callocation[i] === PlantSimEngine.refvalue(sim.statuses[:Internode][j], :carbon_allocation) - end - - for (j, i) in enumerate([2, 4]) - @test ref_values_callocation[i] === PlantSimEngine.refvalue(sim.statuses[:Leaf][j], :carbon_allocation) - end -end - -# Here we initialise var1 to a constant value: -@testset "MTG initialisation" begin - var1 = 1.0 - mapping = ModelMapping( - :Leaf => ( - Process1Model(1.0), - Process2Model(), - Process3Model(), - Status(var1=var1,) - ) - ) - # Need init for var2, so it returns an error: - if VERSION < v"1.8" # We test differently depending on the julia version because the format of the error message changed - @test_throws ErrorException PlantSimEngine.init_simulation(mtg, mapping) - else - @test_throws "Variable `var2` is not computed by any model, not initialised by the user in the status, and not found in the MTG at scale Leaf (checked for MTG node 5)." PlantSimEngine.init_simulation(mtg, mapping) - end - - mapping = ModelMapping( - :Leaf => ( - Process1Model(1.0), - Process2Model(), - Process3Model(), - Status(var1=var1, var2=1.0) - ) - ) - - #out = @test_nowarn PlantSimEngine.run!(mtg, mapping, meteo) - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg, mapping, nsteps=nsteps, check=false, outputs=nothing) - out = run!(sim,meteo) - - @test sim.statuses[:Leaf][1].var1 === var1 - @test sim.statuses[:Leaf][1].var2 === 1.0 - @test sim.statuses[:Leaf][1].var3 === 2.0 - @test sim.statuses[:Leaf][1].var6 === 40.4 -end - -@testset "MTG with complex mapping" begin - mapping = ModelMapping( - :Plant => ( - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - # inputs - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - # outputs - :carbon_allocation => [:Leaf, :Internode] - ] - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - ), - :Internode => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(1.5, 0.06, 25.0, 0.6, 0.004), - Status(TT=10.0, carbon_biomass=1.0) - ), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], - # Notice we provide :Soil, not [:Soil], so a single value is expected here - ), - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - Process1Model(1.0), - Process2Model(), - Process3Model(), - Process4Model(), - Process5Model(), - Process6Model(), - Status(aPPFD=1300.0, TT=10.0, var0=1.0, var9=1.0, carbon_biomass=1.0), - ), - :Soil => ( - ToySoilWaterModel(), - ), - ) - - #out = @test_nowarn PlantSimEngine.run!(mtg, mapping, meteo, executor=SequentialEx()) - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg, mapping, nsteps=nsteps, check=false, outputs=nothing) - out = run!(sim,meteo) - - @test length(sim.dependency_graph.roots) == 6 - @test sim.statuses[:Leaf][1].var1 === 1.01 - @test sim.statuses[:Leaf][1].var2 === 1.03 - @test sim.statuses[:Leaf][1].var4 ≈ 8.1612000000000013 atol = 1e-6 - @test sim.statuses[:Leaf][1].var5 == 32.4806 - @test sim.statuses[:Leaf][1].var8 ≈ 1321.0700490800002 atol = 1e-6 -end - -@testset "MTG with dynamic output variables" begin - mapping = ModelMapping( - :Plant => ( - MultiScaleModel( - model=ToyCAllocationModel(), - mapped_variables=[ - # inputs - :carbon_assimilation => [:Leaf], - :carbon_demand => [:Leaf, :Internode], - # outputs - :carbon_allocation => [:Leaf, :Internode] - ], - ), - MultiScaleModel( - model=ToyPlantRmModel(), - mapped_variables=[:Rm_organs => [:Leaf => :Rm, :Internode => :Rm],], - ), - ), - :Internode => ( - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - Status(TT=10.0, carbon_biomass=1.0) - ), - :Leaf => ( - MultiScaleModel( - model=ToyAssimModel(), - mapped_variables=[:soil_water_content => (:Soil => :soil_water_content),], - # Notice we provide :Soil, not [:Soil], so a single value is expected here - ), - ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), - ToyMaintenanceRespirationModel(2.1, 0.06, 25.0, 1.0, 0.025), - Process1Model(1.0), - Process2Model(), - Process3Model(), - Process4Model(), - Process5Model(), - Process6Model(), - Status(aPPFD=1300.0, TT=10.0, var0=1.0, var9=1.0, carbon_biomass=1.0), - ), - :Soil => ( - ToySoilWaterModel(), - ), - ) - - out_vars = Dict( - :Leaf => (:carbon_assimilation, :carbon_demand, :soil_water_content, :carbon_allocation), - :Internode => (:carbon_allocation,), - :Plant => (:carbon_allocation,), - :Soil => (:soil_water_content,), - ) - - #out = @test_nowarn PlantSimEngine.run!(mtg, mapping, meteo, tracked_outputs=out_vars, executor=SequentialEx()) - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg, mapping, nsteps=nsteps, check=true, outputs=out_vars) - out = run!(sim,meteo) - - @test length(sim.dependency_graph.roots) == 6 - @test sim.statuses[:Leaf][1].var1 === 1.01 - @test sim.statuses[:Leaf][1].var2 === 1.03 - @test sim.statuses[:Leaf][1].var4 ≈ 8.1612000000000013 atol = 1e-6 - @test sim.statuses[:Leaf][1].var5 == 32.4806 - @test sim.statuses[:Leaf][1].var8 ≈ 1321.0700490800002 atol = 1e-6 - - @test unique([sim.outputs[:Leaf][i][:carbon_demand] == 0.5 for i in 1:4]) == [1] - @test sim.outputs[:Leaf][1][:soil_water_content] == sim.outputs[:Soil][1][:soil_water_content] - @test sim.outputs[:Leaf][2][:soil_water_content] == sim.outputs[:Soil][2][:soil_water_content] - - @test all([sim.outputs[:Leaf][i][:carbon_allocation] == sim.outputs[:Internode][i][:carbon_allocation] for i in 1:4])==1 - @test sim.outputs[:Plant][1][:carbon_allocation][1] === sim.outputs[:Internode][1][:carbon_allocation] - - # Testing the outputs if transformed into a DataFrame: - outs_df_dict = convert_outputs(out, DataFrame) - - @test isa(outs_df_dict, Dict{Symbol, DataFrame}) - @test size(outs_df_dict[:Leaf]) == (4, 6) - @test size(outs_df_dict[:Internode]) == (4, 3) - @test size(outs_df_dict[:Soil]) == (2, 3) - @test size(outs_df_dict[:Plant]) == (2, 2) - - @test sort(collect(keys(outs_df_dict))) == sort(collect(keys(out_vars))) - for organ in keys(outs_df_dict) - @test unique(outs_df_dict[organ][!, :timestep]) == [1, 2] - end - # output structure change makes this test obsolete without any trivial equivalent - #@test length(filter(x -> x !== nothing, outs.carbon_assimilation)) == length(filter(x -> x, traverse(mtg, node -> MultiScaleTreeGraph.scale(node) == 2))) - - # TODO, find some equivalent with new output structure - #A = outputs(out, :carbon_assimilation) - #@test A == outs.carbon_assimilation - - #A2 = outputs(out, 5) - #@test A == A2 -end diff --git a/test/test-multirate-output-export.jl b/test/test-multirate-output-export.jl deleted file mode 100644 index 30162d09d..000000000 --- a/test/test-multirate-output-export.jl +++ /dev/null @@ -1,197 +0,0 @@ -using PlantSimEngine -using MultiScaleTreeGraph -using PlantMeteo -using DataFrames -using Test - -PlantSimEngine.@process "mrexportsource" verbose = false -struct MRExportSourceModel <: AbstractMrexportsourceModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRExportSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRExportSourceModel) = (X=-Inf,) -function PlantSimEngine.run!(m::MRExportSourceModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.X = float(m.n[]) -end - -PlantSimEngine.@process "mrdefaultleafsource" verbose = false -struct MRDefaultLeafSourceModel <: AbstractMrdefaultleafsourceModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRDefaultLeafSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRDefaultLeafSourceModel) = (X=-Inf,) -function PlantSimEngine.run!(m::MRDefaultLeafSourceModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.X = float(m.n[]) -end - -PlantSimEngine.@process "mrdefaultplantagg" verbose = false -struct MRDefaultPlantAggModel <: AbstractMrdefaultplantaggModel end -PlantSimEngine.inputs_(::MRDefaultPlantAggModel) = (X=-Inf,) -PlantSimEngine.outputs_(::MRDefaultPlantAggModel) = (XP=-Inf,) -function PlantSimEngine.run!(::MRDefaultPlantAggModel, models, status, meteo, constants=nothing, extra=nothing) - status.XP = sum(status.X) + 100.0 -end -PlantSimEngine.timespec(::Type{<:MRDefaultPlantAggModel}) = ClockSpec(2.0, 1.0) - -PlantSimEngine.@process "mrdefaultsceneagg" verbose = false -struct MRDefaultSceneAggModel <: AbstractMrdefaultsceneaggModel end -PlantSimEngine.inputs_(::MRDefaultSceneAggModel) = (XP=-Inf,) -PlantSimEngine.outputs_(::MRDefaultSceneAggModel) = (XS=-Inf,) -function PlantSimEngine.run!(::MRDefaultSceneAggModel, models, status, meteo, constants=nothing, extra=nothing) - status.XS = sum(status.XP) + 1000.0 -end -PlantSimEngine.timespec(::Type{<:MRDefaultSceneAggModel}) = ClockSpec(4.0, 1.0) - -@testset "Multi-rate output export API" begin - mtg = Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) - plant = Node(mtg, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - internode = Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) - Node(internode, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - - meteo4 = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65)], 4)) - - # Stream-only producer remains exportable when process is explicit. - mapping_stream = ModelMapping( - :Leaf => ( - ModelSpec(MRExportSourceModel(Ref(0))) |> - TimeStepModel(1.0) |> - OutputRouting(; X=:stream_only), - ), - ) - - req_hold = OutputRequest(:Leaf, :X; name=:x_hold, process=:mrexportsource, policy=HoldLast()) - req_sum2 = OutputRequest(:Leaf, :X; name=:x_sum2, process=:mrexportsource, policy=Integrate(), clock=ClockSpec(2.0, 1.0)) - - sim_stream = PlantSimEngine.GraphSimulation(mtg, mapping_stream, nsteps=4, check=true, outputs=Dict(:Leaf => (:X,))) - run!( - sim_stream, - meteo4, - executor=SequentialEx(), - tracked_outputs=[req_hold, req_sum2], - ) - exported = collect_outputs(sim_stream; sink=DataFrame) - - @test exported[:x_hold][:, :timestep] == [1, 2, 3, 4] - @test exported[:x_hold][:, :value] == [1.0, 2.0, 3.0, 4.0] - @test exported[:x_sum2][:, :timestep] == [1, 3] - @test exported[:x_sum2][:, :value] == [1.0, 5.0] - - # Without process and with stream-only routing, canonical source resolution should fail. - @test_throws "No canonical publisher found" run!( - sim_stream, - meteo4, - executor=SequentialEx(), - tracked_outputs=[OutputRequest(:Leaf, :X; name=:x_auto_fail)], - ) - - # Canonical routing allows omitting process in requests. - mapping_canonical = ModelMapping( - :Leaf => ( - ModelSpec(MRExportSourceModel(Ref(0))) |> - TimeStepModel(1.0), - ), - ) - - sim_canonical = PlantSimEngine.GraphSimulation(mtg, mapping_canonical, nsteps=4, check=true, outputs=Dict(:Leaf => (:X,))) - run!( - sim_canonical, - meteo4, - executor=SequentialEx(), - tracked_outputs=[OutputRequest(:Leaf, :X; name=:x_auto, policy=HoldLast())], - ) - exported_auto = collect_outputs(sim_canonical; sink=DataFrame) - @test exported_auto[:x_auto][:, :value] == [1.0, 2.0, 3.0, 4.0] - - # Optional direct export return from run! on GraphSimulation. - sim_direct = PlantSimEngine.GraphSimulation( - mtg, - ModelMapping( - :Leaf => ( - ModelSpec(MRExportSourceModel(Ref(0))) |> - TimeStepModel(1.0), - ), - ), - nsteps=4, - check=true, - outputs=Dict(:Leaf => (:X,)), - ) - out_status, out_requested = run!( - sim_direct, - meteo4, - executor=SequentialEx(), - tracked_outputs=[OutputRequest(:Leaf, :X; name=:x_direct, policy=HoldLast())], - return_requested_outputs=true, - ) - @test haskey(out_status, :Leaf) - @test out_requested[:x_direct][:, :value] == [1.0, 2.0, 3.0, 4.0] - - # Optional direct export return from run! on MTG + mapping entry point. - out_status_mtg, out_requested_mtg = run!( - mtg, - Dict( - :Leaf => ( - ModelSpec(MRExportSourceModel(Ref(0))) |> - TimeStepModel(1.0), - ), - ), - meteo4; - executor=SequentialEx(), - tracked_outputs=[OutputRequest(:Leaf, :X; name=:x_mtg, policy=HoldLast())], - return_requested_outputs=true, - ) - @test haskey(out_status_mtg, :Leaf) - @test out_requested_mtg[:x_mtg][:, :value] == [1.0, 2.0, 3.0, 4.0] -end - -@testset "Multi-rate output export defaults on multi-scale mapping with timespec traits" begin - mtg = Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) - plant = Node(mtg, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - internode = Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) - Node(internode, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - - meteo8 = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65)], 8)) - - mapping_defaults = ModelMapping( - :Leaf => ( - MultiScaleModel( - model=MRDefaultLeafSourceModel(Ref(0)), - mapped_variables=[:X => (:Plant => :X)], - ), - ), - :Plant => ( - MultiScaleModel( - model=MRDefaultPlantAggModel(), - mapped_variables=[:XP => (:Scene => :XP)], - ), - ), - :Scene => ( - MRDefaultSceneAggModel(), - ), - ) - - sim_defaults = PlantSimEngine.GraphSimulation( - mtg, - mapping_defaults, - nsteps=8, - check=true, - outputs=Dict(:Leaf => (:X,), :Plant => (:XP,), :Scene => (:XS,)), - ) - - run!( - sim_defaults, - meteo8, - executor=SequentialEx(), - tracked_outputs=[ - OutputRequest(:Plant, :XP), - OutputRequest(:Scene, :XS), - ], - ) - - exported_defaults = collect_outputs(sim_defaults; sink=DataFrame) - - @test sort(collect(keys(exported_defaults))) == [:XP, :XS] - @test exported_defaults[:XP][:, :timestep] == collect(1:8) - @test exported_defaults[:XS][:, :timestep] == collect(1:8) -end diff --git a/test/test-multirate-runtime.jl b/test/test-multirate-runtime.jl deleted file mode 100644 index 2d88c7fd2..000000000 --- a/test/test-multirate-runtime.jl +++ /dev/null @@ -1,1384 +0,0 @@ -using PlantSimEngine -using PlantSimEngine.Examples -using MultiScaleTreeGraph -using PlantMeteo -using DataFrames -using Test -using Dates - -const _HAS_METEO_SAMPLER_API = isdefined(PlantMeteo, :prepare_weather_sampler) && - isdefined(PlantMeteo, :RollingWindow) && - isdefined(PlantMeteo, :sample_weather) - -# Producer stream: writes :S. -PlantSimEngine.@process "mrsource" verbose = false -struct MRSourceModel <: AbstractMrsourceModel end -PlantSimEngine.inputs_(::MRSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRSourceModel) = (S=-Inf,) -function PlantSimEngine.run!(::MRSourceModel, models, status, meteo, constants=nothing, extra=nothing) - status.S = 10.0 -end - -# Writes :C in status, but is declared stream-only for canonical publication checks. -PlantSimEngine.@process "mroverwrite" verbose = false -struct MROverwriteModel <: AbstractMroverwriteModel end -PlantSimEngine.inputs_(::MROverwriteModel) = (S=-Inf,) -PlantSimEngine.outputs_(::MROverwriteModel) = (C=-Inf,) -function PlantSimEngine.run!(::MROverwriteModel, models, status, meteo, constants=nothing, extra=nothing) - status.C = -999.0 -end - -# Consumer reads :C and writes :B. -PlantSimEngine.@process "mrconsumer" verbose = false -struct MRConsumerModel <: AbstractMrconsumerModel end -PlantSimEngine.inputs_(::MRConsumerModel) = (C=-Inf,) -PlantSimEngine.outputs_(::MRConsumerModel) = (B=-Inf,) -function PlantSimEngine.run!(::MRConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.B = status.C -end - -# Direct mapping case: same variable name on producer and consumer (:S -> :S). -PlantSimEngine.@process "mrdirectconsumer" verbose = false -struct MRDirectConsumerModel <: AbstractMrdirectconsumerModel end -PlantSimEngine.inputs_(::MRDirectConsumerModel) = (S=-Inf,) -PlantSimEngine.outputs_(::MRDirectConsumerModel) = (D=-Inf,) -function PlantSimEngine.run!(::MRDirectConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.D = status.S -end - -PlantSimEngine.@process "mrautosamename" verbose = false -struct MRAutoSameNameModel <: AbstractMrautosamenameModel end -PlantSimEngine.inputs_(::MRAutoSameNameModel) = (S=-Inf,) -PlantSimEngine.outputs_(::MRAutoSameNameModel) = (E=-Inf,) -function PlantSimEngine.run!(::MRAutoSameNameModel, models, status, meteo, constants=nothing, extra=nothing) - status.E = status.S -end - -PlantSimEngine.@process "mrconflict1" verbose = false -struct MRConflict1Model <: AbstractMrconflict1Model end -PlantSimEngine.inputs_(::MRConflict1Model) = NamedTuple() -PlantSimEngine.outputs_(::MRConflict1Model) = (Z=-Inf,) -function PlantSimEngine.run!(::MRConflict1Model, models, status, meteo, constants=nothing, extra=nothing) - status.Z = 1.0 -end - -PlantSimEngine.@process "mrconflict2" verbose = false -struct MRConflict2Model <: AbstractMrconflict2Model end -PlantSimEngine.inputs_(::MRConflict2Model) = NamedTuple() -PlantSimEngine.outputs_(::MRConflict2Model) = (Z=-Inf,) -function PlantSimEngine.run!(::MRConflict2Model, models, status, meteo, constants=nothing, extra=nothing) - status.Z = 2.0 -end - -PlantSimEngine.@process "mrancestorsource" verbose = false -struct MRAncestorSourceModel <: AbstractMrancestorsourceModel end -PlantSimEngine.inputs_(::MRAncestorSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRAncestorSourceModel) = (Z=-Inf,) -function PlantSimEngine.run!(::MRAncestorSourceModel, models, status, meteo, constants=nothing, extra=nothing) - status.Z = 11.0 -end - -PlantSimEngine.@process "mrsiblingsource" verbose = false -struct MRSiblingSourceModel <: AbstractMrsiblingsourceModel end -PlantSimEngine.inputs_(::MRSiblingSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRSiblingSourceModel) = (Z=-Inf,) -function PlantSimEngine.run!(::MRSiblingSourceModel, models, status, meteo, constants=nothing, extra=nothing) - status.Z = 22.0 -end - -PlantSimEngine.@process "mrclocksource" verbose = false -struct MRClockSourceModel <: AbstractMrclocksourceModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRClockSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRClockSourceModel) = (X=-Inf,) -function PlantSimEngine.run!(m::MRClockSourceModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.X = float(m.n[]) -end - -PlantSimEngine.@process "mrclockconsumer" verbose = false -struct MRClockConsumerModel <: AbstractMrclockconsumerModel end -PlantSimEngine.inputs_(::MRClockConsumerModel) = (X=-Inf,) -PlantSimEngine.outputs_(::MRClockConsumerModel) = (Y=-Inf,) -function PlantSimEngine.run!(::MRClockConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.Y = status.X -end -PlantSimEngine.timespec(::Type{<:MRClockConsumerModel}) = ClockSpec(2.0, 1.0) - -PlantSimEngine.@process "mrcrosssource" verbose = false -struct MRCrossSourceModel <: AbstractMrcrosssourceModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRCrossSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRCrossSourceModel) = (XS=-Inf,) -function PlantSimEngine.run!(m::MRCrossSourceModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.XS = float(m.n[]) -end - -PlantSimEngine.@process "mrcrossconsumer" verbose = false -struct MRCrossConsumerModel <: AbstractMrcrossconsumerModel end -PlantSimEngine.inputs_(::MRCrossConsumerModel) = (XS=-Inf,) -PlantSimEngine.outputs_(::MRCrossConsumerModel) = (XP=-Inf,) -function PlantSimEngine.run!(::MRCrossConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.XP = sum(status.XS) -end - -PlantSimEngine.@process "mrinterpsource" verbose = false -struct MRInterpSourceModel <: AbstractMrinterpsourceModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRInterpSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRInterpSourceModel) = (XI=-Inf,) -function PlantSimEngine.run!(m::MRInterpSourceModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.XI = 2.0 * m.n[] - 1.0 -end - -PlantSimEngine.@process "mrinterpconsumer" verbose = false -struct MRInterpConsumerModel <: AbstractMrinterpconsumerModel end -PlantSimEngine.inputs_(::MRInterpConsumerModel) = (XI=-Inf,) -PlantSimEngine.outputs_(::MRInterpConsumerModel) = (YI=-Inf,) -function PlantSimEngine.run!(::MRInterpConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.YI = status.XI -end - -PlantSimEngine.@process "mraggsource" verbose = false -struct MRAggSourceModel <: AbstractMraggsourceModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRAggSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRAggSourceModel) = (XA=-Inf,) -function PlantSimEngine.run!(m::MRAggSourceModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.XA = float(m.n[]) -end - -PlantSimEngine.@process "mraggconsumer" verbose = false -struct MRAggConsumerModel <: AbstractMraggconsumerModel end -PlantSimEngine.inputs_(::MRAggConsumerModel) = (XA=-Inf,) -PlantSimEngine.outputs_(::MRAggConsumerModel) = (YA=-Inf,) -function PlantSimEngine.run!(::MRAggConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.YA = status.XA -end - -PlantSimEngine.@process "mrtraitaggsource" verbose = false -struct MRTraitAggSourceModel <: AbstractMrtraitaggsourceModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRTraitAggSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRTraitAggSourceModel) = (XT=-Inf,) -function PlantSimEngine.run!(m::MRTraitAggSourceModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.XT = float(m.n[]) -end -PlantSimEngine.output_policy(::Type{<:MRTraitAggSourceModel}) = (XT=Aggregate(),) - -PlantSimEngine.@process "mrtraitaggconsumer" verbose = false -struct MRTraitAggConsumerModel <: AbstractMrtraitaggconsumerModel end -PlantSimEngine.inputs_(::MRTraitAggConsumerModel) = (XT=-Inf,) -PlantSimEngine.outputs_(::MRTraitAggConsumerModel) = (YT=-Inf,) -function PlantSimEngine.run!(::MRTraitAggConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.YT = status.XT -end - -PlantSimEngine.@process "mrtraitdualaggsource" verbose = false -struct MRTraitDualAggSourceModel <: AbstractMrtraitdualaggsourceModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRTraitDualAggSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRTraitDualAggSourceModel) = (V1=-Inf, V2=-Inf) -function PlantSimEngine.run!(m::MRTraitDualAggSourceModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.V1 = float(m.n[]) - status.V2 = 100.0 + float(m.n[]) -end -PlantSimEngine.output_policy(::Type{<:MRTraitDualAggSourceModel}) = (V1=Aggregate(), V2=Aggregate()) - -PlantSimEngine.@process "mrusesvconsumer" verbose = false -struct MRUsesV1ConsumerModel <: AbstractMrusesvconsumerModel end -PlantSimEngine.inputs_(::MRUsesV1ConsumerModel) = (V1=-Inf,) -PlantSimEngine.outputs_(::MRUsesV1ConsumerModel) = (YV1=-Inf,) -function PlantSimEngine.run!(::MRUsesV1ConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.YV1 = status.V1 -end - -PlantSimEngine.@process "mrdualpolicyconsumer" verbose = false -struct MRDualPolicyConsumerModel <: AbstractMrdualpolicyconsumerModel end -PlantSimEngine.inputs_(::MRDualPolicyConsumerModel) = (V1=-Inf, V2=-Inf, V1_max=-Inf) -PlantSimEngine.outputs_(::MRDualPolicyConsumerModel) = (YV1=-Inf, YV2=-Inf, YV1_max=-Inf) -function PlantSimEngine.run!(::MRDualPolicyConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.YV1 = status.V1 - status.YV2 = status.V2 - status.YV1_max = status.V1_max -end - -PlantSimEngine.@process "mrdailysource" verbose = false -struct MRDailySourceModel <: AbstractMrdailysourceModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRDailySourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRDailySourceModel) = (XD=-Inf,) -function PlantSimEngine.run!(m::MRDailySourceModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.XD = float(m.n[]) -end - -PlantSimEngine.@process "mrhourlyfromdailyconsumer" verbose = false -struct MRHourlyFromDailyConsumerModel <: AbstractMrhourlyfromdailyconsumerModel end -PlantSimEngine.inputs_(::MRHourlyFromDailyConsumerModel) = (XD=-Inf,) -PlantSimEngine.outputs_(::MRHourlyFromDailyConsumerModel) = (YD=-Inf,) -function PlantSimEngine.run!(::MRHourlyFromDailyConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.YD = status.XD -end - -PlantSimEngine.@process "mrzconsumer" verbose = false -struct MRZConsumerModel <: AbstractMrzconsumerModel end -PlantSimEngine.inputs_(::MRZConsumerModel) = (Z=-Inf,) -PlantSimEngine.outputs_(::MRZConsumerModel) = (ZZ=-Inf,) -function PlantSimEngine.run!(::MRZConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.ZZ = status.Z -end - -PlantSimEngine.@process "mrmultiscaletsource" verbose = false -struct MRMultiScaleTSourceModel <: AbstractMrmultiscaletsourceModel - tt::Float64 -end -PlantSimEngine.inputs_(::MRMultiScaleTSourceModel) = NamedTuple() -PlantSimEngine.outputs_(::MRMultiScaleTSourceModel) = (TT=-Inf,) -function PlantSimEngine.run!(m::MRMultiScaleTSourceModel, models, status, meteo, constants=nothing, extra=nothing) - status.TT = m.tt -end - -PlantSimEngine.@process "mrleafttconsumer" verbose = false -struct MRLeafTTConsumerModel <: AbstractMrleafttconsumerModel end -PlantSimEngine.inputs_(::MRLeafTTConsumerModel) = (TT=-Inf,) -PlantSimEngine.outputs_(::MRLeafTTConsumerModel) = (TT_used=-Inf,) -function PlantSimEngine.run!(::MRLeafTTConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.TT_used = status.TT -end - -PlantSimEngine.@process "mrmissinginputconsumer" verbose = false -struct MRMissingInputConsumerModel <: AbstractMrmissinginputconsumerModel end -PlantSimEngine.inputs_(::MRMissingInputConsumerModel) = (U=-Inf,) -PlantSimEngine.outputs_(::MRMissingInputConsumerModel) = (OU=-Inf,) -function PlantSimEngine.run!(::MRMissingInputConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.OU = status.U -end - -PlantSimEngine.@process "mrhardchild" verbose = false -struct MRHardChildModel <: AbstractMrhardchildModel end -PlantSimEngine.inputs_(::MRHardChildModel) = NamedTuple() -PlantSimEngine.outputs_(::MRHardChildModel) = (A=-Inf,) -function PlantSimEngine.run!(::MRHardChildModel, models, status, meteo, constants=nothing, extra=nothing) - status.A = 1.0 -end - -PlantSimEngine.@process "mrhardparent" verbose = false -struct MRHardParentModel <: AbstractMrhardparentModel end -PlantSimEngine.dep(::MRHardParentModel) = (mrhardchild=AbstractMrhardchildModel,) -PlantSimEngine.inputs_(::MRHardParentModel) = NamedTuple() -PlantSimEngine.outputs_(::MRHardParentModel) = (A=-Inf,) -function PlantSimEngine.run!(::MRHardParentModel, models, status, meteo, constants=nothing, extra=nothing) - run!(models.mrhardchild, models, status, meteo, constants, extra) - status.A = 5.0 -end - -PlantSimEngine.@process "mrhardconsumer" verbose = false -struct MRHardConsumerModel <: AbstractMrhardconsumerModel end -PlantSimEngine.inputs_(::MRHardConsumerModel) = (A=-Inf,) -PlantSimEngine.outputs_(::MRHardConsumerModel) = (B=-Inf,) -function PlantSimEngine.run!(::MRHardConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.B = status.A -end - -PlantSimEngine.@process "mrmeteodailyconsumer" verbose = false -struct MRMeteoDailyConsumerModel <: AbstractMrmeteodailyconsumerModel end -PlantSimEngine.inputs_(::MRMeteoDailyConsumerModel) = NamedTuple() -PlantSimEngine.outputs_(::MRMeteoDailyConsumerModel) = (MT=-Inf, MTmin=-Inf, MTmax=-Inf, MRh=-Inf, MSW=-Inf, MSWq=-Inf) -function PlantSimEngine.run!(::MRMeteoDailyConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.MT = meteo.T - status.MTmin = meteo.Tmin - status.MTmax = meteo.Tmax - status.MRh = meteo.Rh - status.MSW = meteo.Ri_SW_f - status.MSWq = meteo.Ri_SW_q -end - -PlantSimEngine.@process "mrmeteocustomconsumer" verbose = false -struct MRMeteoCustomConsumerModel <: AbstractMrmeteocustomconsumerModel end -PlantSimEngine.inputs_(::MRMeteoCustomConsumerModel) = NamedTuple() -PlantSimEngine.outputs_(::MRMeteoCustomConsumerModel) = (MRQ=-Inf, MCV=-Inf) -function PlantSimEngine.run!(::MRMeteoCustomConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.MRQ = meteo.Ri_SW_f - status.MCV = meteo.custom_peak -end - -PlantSimEngine.@process "mrrangehinta" verbose = false -struct MRRangeHintAModel <: AbstractMrrangehintaModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRRangeHintAModel) = NamedTuple() -PlantSimEngine.outputs_(::MRRangeHintAModel) = (XA=-Inf,) -function PlantSimEngine.run!(m::MRRangeHintAModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.XA = float(m.n[]) -end -PlantSimEngine.timestep_hint(::Type{<:MRRangeHintAModel}) = (; required=(Dates.Hour(1), Dates.Hour(4)), preferred=Dates.Hour(3)) - -PlantSimEngine.@process "mrrangehintb" verbose = false -struct MRRangeHintBModel <: AbstractMrrangehintbModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRRangeHintBModel) = NamedTuple() -PlantSimEngine.outputs_(::MRRangeHintBModel) = (XB=-Inf,) -function PlantSimEngine.run!(m::MRRangeHintBModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.XB = float(m.n[]) -end -PlantSimEngine.timestep_hint(::Type{<:MRRangeHintBModel}) = (; required=(Dates.Hour(1), Dates.Hour(6)), preferred=Dates.Hour(4)) - -PlantSimEngine.@process "mrrangehintforced" verbose = false -struct MRRangeHintForcedModel <: AbstractMrrangehintforcedModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRRangeHintForcedModel) = NamedTuple() -PlantSimEngine.outputs_(::MRRangeHintForcedModel) = (XF=-Inf,) -function PlantSimEngine.run!(m::MRRangeHintForcedModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.XF = float(m.n[]) -end -PlantSimEngine.timestep_hint(::Type{<:MRRangeHintForcedModel}) = (Dates.Hour(3), Dates.Hour(6)) - -PlantSimEngine.@process "mrrangehintstrict" verbose = false -struct MRRangeHintStrictModel <: AbstractMrrangehintstrictModel - n::Base.RefValue{Int} -end -PlantSimEngine.inputs_(::MRRangeHintStrictModel) = NamedTuple() -PlantSimEngine.outputs_(::MRRangeHintStrictModel) = (XS=-Inf,) -function PlantSimEngine.run!(m::MRRangeHintStrictModel, models, status, meteo, constants=nothing, extra=nothing) - m.n[] += 1 - status.XS = float(m.n[]) -end -PlantSimEngine.timestep_hint(::Type{<:MRRangeHintStrictModel}) = (Dates.Hour(2), Dates.Hour(4)) - -PlantSimEngine.@process "mrmeteohintconsumer" verbose = false -struct MRMeteoHintConsumerModel <: AbstractMrmeteohintconsumerModel end -PlantSimEngine.inputs_(::MRMeteoHintConsumerModel) = NamedTuple() -PlantSimEngine.outputs_(::MRMeteoHintConsumerModel) = (HT=-Inf, HSWQ=-Inf) -function PlantSimEngine.run!(::MRMeteoHintConsumerModel, models, status, meteo, constants=nothing, extra=nothing) - status.HT = meteo.T - status.HSWQ = meteo.Ri_SW_q -end -PlantSimEngine.timestep_hint(::Type{<:MRMeteoHintConsumerModel}) = Dates.Day(1) -PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( - bindings=( - T=(source=:T, reducer=MaxReducer()), - Ri_SW_q=(source=:Ri_SW_f, reducer=RadiationEnergy()), - ), - window=CalendarWindow(:day; anchor=:current_period, week_start=1, completeness=:allow_partial), -) - -@testset "Multi-rate runtime: HoldLast and conflict validation" begin - mtg = Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) - plant = Node(mtg, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - Node(mtg, MultiScaleTreeGraph.NodeMTG("+", :Soil, 1, 1)) - internode = Node(plant, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) - Node(internode, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - - mapping_ok = ModelMapping( - :Leaf => ( - MRSourceModel(), - ModelSpec(MROverwriteModel()) |> OutputRouting(; C=:stream_only), - ModelSpec(MRConsumerModel()) |> - InputBindings(; C=(process=:mrsource, var=:S)), - ModelSpec(MRDirectConsumerModel()) |> - InputBindings(; S=(process=:mrsource, var=:S)), - MRAutoSameNameModel(), - ), - ) - - out_ok = Dict(:Leaf => (:S, :C, :B, :D, :E)) - sim_ok = PlantSimEngine.GraphSimulation(mtg, mapping_ok, nsteps=1, check=true, outputs=out_ok) - meteo = Atmosphere(T=20.0, Wind=1.0, Rh=0.65) - run!(sim_ok, meteo, executor=SequentialEx()) - - specs_leaf = PlantSimEngine.get_model_specs(sim_ok)[:Leaf] - @test input_bindings(specs_leaf[:mrconsumer]).C.var == :S - @test input_bindings(specs_leaf[:mrconsumer]).C.policy isa HoldLast - @test output_routing(specs_leaf[:mroverwrite]).C == :stream_only - @test input_bindings(specs_leaf[:mrautosamename]).S.process == :mrsource - @test input_bindings(specs_leaf[:mrautosamename]).S.var == :S - - st_leaf = status(sim_ok)[:Leaf][1] - # Expectation 1: consumer :C input is remapped from mrsource/:S via mapping-level InputBindings. - @test st_leaf.C == 10.0 - @test st_leaf.B == 10.0 - # Expectation 2: direct same-name binding (:S -> :S) also resolves and is visible in :D. - @test st_leaf.D == 10.0 - # Expectation 3: with no input_bindings method, same-name input :S is auto-resolved. - @test st_leaf.E == 10.0 - nid = MultiScaleTreeGraph.node_id(st_leaf.node) - scope = ScopeId(:global, 1) - key_src = OutputKey(scope, :Leaf, nid, :mrsource, :S) - key_ovr = OutputKey(scope, :Leaf, nid, :mroverwrite, :C) - key_dir = OutputKey(scope, :Leaf, nid, :mrdirectconsumer, :D) - key_auto = OutputKey(scope, :Leaf, nid, :mrautosamename, :E) - # Expectation 4: temporal stream caches track producer outputs (including stream-only outputs). - @test haskey(sim_ok.temporal_state.caches, key_src) - @test haskey(sim_ok.temporal_state.caches, key_ovr) - @test haskey(sim_ok.temporal_state.caches, key_dir) - @test haskey(sim_ok.temporal_state.caches, key_auto) - @test sim_ok.temporal_state.caches[key_src].v == 10.0 - @test sim_ok.temporal_state.caches[key_ovr].v == -999.0 - @test sim_ok.temporal_state.caches[key_dir].v == 10.0 - @test sim_ok.temporal_state.caches[key_auto].v == 10.0 - - mapping_conflict = ModelMapping( - :Leaf => ( - ModelSpec(MRConflict1Model()) |> TimeStepModel(1.0), - ModelSpec(MRConflict2Model()) |> TimeStepModel(1.0), - ), - ) - sim_conflict = PlantSimEngine.GraphSimulation(mtg, mapping_conflict, nsteps=1, check=true, outputs=Dict(:Leaf => (:Z,))) - # Expectation 5: two canonical publishers of the same output are rejected. - @test_throws "Ambiguous canonical publishers" run!(sim_conflict, meteo, executor=SequentialEx()) - - # Expectation 6: models run at different clocks; slower model holds last value between runs. - source_counter = Ref(0) - mapping_clock_trait = ModelMapping( - :Leaf => ( - ModelSpec(MRClockSourceModel(source_counter)) |> TimeStepModel(1.0), - ModelSpec(MRClockConsumerModel()) |> - InputBindings(; X=(process=:mrclocksource, var=:X)), - ), - ) - sim_clock_trait = PlantSimEngine.GraphSimulation(mtg, mapping_clock_trait, nsteps=4, check=true, outputs=Dict(:Leaf => (:X, :Y))) - meteo4 = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65)], 4)) - run!(sim_clock_trait, meteo4, executor=SequentialEx()) - source_counter[] = 0 - out_status_auto, out_requested_auto = run!( - mtg, - mapping_clock_trait, - meteo4; - executor=SequentialEx(), - tracked_outputs=[OutputRequest(:Leaf, :X; process=:mrclocksource, policy=HoldLast(), name=:x_auto)], - return_requested_outputs=true, - ) - @test haskey(out_status_auto, :Leaf) - @test out_requested_auto[:x_auto][:, :value] == [1.0, 2.0, 3.0, 4.0] - st_clock = status(sim_clock_trait)[:Leaf][1] - @test st_clock.X == 4.0 - @test st_clock.Y == 3.0 - scope = ScopeId(:global, 1) - @test sim_clock_trait.temporal_state.last_run[ModelKey(scope, :Leaf, :mrclocksource)] == 4.0 - @test sim_clock_trait.temporal_state.last_run[ModelKey(scope, :Leaf, :mrclockconsumer)] == 3.0 - - # Expectation 7: TimeStepModel override takes precedence over model timespec. - source_counter_2 = Ref(0) - mapping_clock_override = ModelMapping( - :Leaf => ( - ModelSpec(MRClockSourceModel(source_counter_2)) |> TimeStepModel(1.0), - ModelSpec(MRClockConsumerModel()) |> - TimeStepModel(3.0) |> - InputBindings(; X=(process=:mrclocksource, var=:X)), - ), - ) - sim_clock_override = PlantSimEngine.GraphSimulation(mtg, mapping_clock_override, nsteps=4, check=true, outputs=Dict(:Leaf => (:X, :Y))) - run!(sim_clock_override, meteo4, executor=SequentialEx()) - st_clock_override = status(sim_clock_override)[:Leaf][1] - @test st_clock_override.X == 4.0 - @test st_clock_override.Y == 3.0 - @test sim_clock_override.temporal_state.last_run[ModelKey(scope, :Leaf, :mrclocksource)] == 4.0 - @test sim_clock_override.temporal_state.last_run[ModelKey(scope, :Leaf, :mrclockconsumer)] == 3.0 - - # Expectation 7b: non-sequential executors warn and fall back to sequential behavior. - mapping_clock_fallback_seq = ModelMapping( - :Leaf => ( - ModelSpec(MRClockSourceModel(Ref(0))) |> TimeStepModel(1.0), - ModelSpec(MRClockConsumerModel()) |> - InputBindings(; X=(process=:mrclocksource, var=:X)), - ), - ) - sim_clock_fallback_seq = PlantSimEngine.GraphSimulation(mtg, mapping_clock_fallback_seq, nsteps=4, check=true, outputs=Dict(:Leaf => (:X, :Y))) - out_fallback_seq = run!(sim_clock_fallback_seq, meteo4, executor=SequentialEx()) - out_fallback_seq_df = convert_outputs(out_fallback_seq, DataFrame) - - mapping_clock_fallback_threaded = ModelMapping( - :Leaf => ( - ModelSpec(MRClockSourceModel(Ref(0))) |> TimeStepModel(1.0), - ModelSpec(MRClockConsumerModel()) |> - InputBindings(; X=(process=:mrclocksource, var=:X)), - ), - ) - sim_clock_fallback_threaded = PlantSimEngine.GraphSimulation(mtg, mapping_clock_fallback_threaded, nsteps=4, check=true, outputs=Dict(:Leaf => (:X, :Y))) - @test_logs (:warn, r"Multi-rate MTG runs currently execute sequentially") begin - out_fallback_threaded = run!(sim_clock_fallback_threaded, meteo4, executor=ThreadedEx()) - out_fallback_threaded_df = convert_outputs(out_fallback_threaded, DataFrame) - @test out_fallback_threaded_df[:Leaf][:, :X] == out_fallback_seq_df[:Leaf][:, :X] - @test out_fallback_threaded_df[:Leaf][:, :Y] == out_fallback_seq_df[:Leaf][:, :Y] - end - - # Expectation 8: cross-scale hold-last resolution works with different clocks. - # Leaf producer runs each step; Plant consumer runs every 2 steps (1, 3) and reads Leaf XS through multiscale mapping. - source_counter_3 = Ref(0) - mapping_cross = ModelMapping( - :Leaf => ( - ModelSpec(MRCrossSourceModel(source_counter_3)) |> TimeStepModel(1.0), - ), - :Plant => ( - ModelSpec(MRCrossConsumerModel()) |> - MultiScaleModel([:XS => [:Leaf]]) |> - TimeStepModel(ClockSpec(2.0, 1.0)) |> - InputBindings(; XS=(process=:mrcrosssource, var=:XS, scale=:Leaf)), - ), - ) - sim_cross = PlantSimEngine.GraphSimulation(mtg, mapping_cross, nsteps=4, check=true, outputs=Dict(:Leaf => (:XS,), :Plant => (:XP,))) - run!(sim_cross, meteo4, executor=SequentialEx()) - st_leaf_cross = status(sim_cross)[:Leaf][1] - st_plant_cross = status(sim_cross)[:Plant][1] - @test st_leaf_cross.XS == 4.0 - @test st_plant_cross.XP == 3.0 - - # Expectation 8a: cross-scale producer is inferred automatically when unique. - source_counter_3_auto = Ref(0) - mapping_cross_auto = ModelMapping( - :Leaf => ( - ModelSpec(MRCrossSourceModel(source_counter_3_auto)) |> TimeStepModel(1.0), - ), - :Plant => ( - ModelSpec(MRCrossConsumerModel()) |> - MultiScaleModel([:XS => [:Leaf]]) |> - TimeStepModel(ClockSpec(2.0, 1.0)), - ), - ) - sim_cross_auto = PlantSimEngine.GraphSimulation(mtg, mapping_cross_auto, nsteps=4, check=true, outputs=Dict(:Leaf => (:XS,), :Plant => (:XP,))) - run!(sim_cross_auto, meteo4, executor=SequentialEx()) - st_plant_cross_auto = status(sim_cross_auto)[:Plant][1] - @test st_plant_cross_auto.XP == 3.0 - spec_cross_auto = PlantSimEngine.get_model_specs(sim_cross_auto)[:Plant][:mrcrossconsumer] - @test input_bindings(spec_cross_auto).XS.process == :mrcrosssource - @test input_bindings(spec_cross_auto).XS.scale == :Leaf - - # Expectation 8b: scope partitioning isolates producer streams between plants. - scene2 = Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) - plant2_a = Node(scene2, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) - plant2_b = Node(scene2, MultiScaleTreeGraph.NodeMTG("+", :Plant, 2, 1)) - internode2_a = Node(plant2_a, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) - internode2_b = Node(plant2_b, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) - Node(internode2_a, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - Node(internode2_b, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) - - source_counter_scoped = Ref(0) - mapping_scoped = ModelMapping( - :Leaf => ( - ModelSpec(MRCrossSourceModel(source_counter_scoped)) |> TimeStepModel(1.0) |> ScopeModel(:plant), - ), - :Plant => ( - ModelSpec(MRCrossConsumerModel()) |> - MultiScaleModel([:XS => [:Leaf]]) |> - TimeStepModel(1.0) |> - ScopeModel(:plant) |> - InputBindings(; XS=(process=:mrcrosssource, var=:XS, scale=:Leaf)), - ), - ) - sim_scoped = PlantSimEngine.GraphSimulation(scene2, mapping_scoped, nsteps=1, check=true, outputs=Dict(:Plant => (:XP,), :Leaf => (:XS,))) - run!(sim_scoped, meteo, executor=SequentialEx()) - plant_vals = sort([st.XP for st in status(sim_scoped)[:Plant]]) - @test plant_vals == [1.0, 2.0] - - function plant_ancestor_id(node) - current = node - while !isnothing(current) && symbol(current) != :Plant - current = parent(current) - end - isnothing(current) && error("Expected a Plant ancestor in scoped test tree.") - return node_id(current) - end - - leaf_scoped_statuses = status(sim_scoped)[:Leaf] - leaf_scoped_keys = [ - OutputKey(ScopeId(:plant, plant_ancestor_id(st.node)), :Leaf, node_id(st.node), :mrcrosssource, :XS) - for st in leaf_scoped_statuses - ] - @test all(k -> haskey(sim_scoped.temporal_state.caches, k), leaf_scoped_keys) - - # Expectation 9: Interpolate policy resolves a slower producer for a faster consumer. - # Source runs at t=1,3,5 with values 1,3,5. - # Consumer runs every step and receives XI through Interpolate: - # expected YI over time is [1, 1, 3, 4, 5]. - interp_counter = Ref(0) - mapping_interp = ModelMapping( - :Leaf => ( - ModelSpec(MRInterpSourceModel(interp_counter)) |> TimeStepModel(ClockSpec(2.0, 1.0)), - ModelSpec(MRInterpConsumerModel()) |> - TimeStepModel(1.0) |> - InputBindings(; XI=(process=:mrinterpsource, var=:XI, policy=Interpolate())), - ), - ) - meteo5 = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65)], 5)) - sim_interp = PlantSimEngine.GraphSimulation(mtg, mapping_interp, nsteps=5, check=true, outputs=Dict(:Leaf => (:YI,))) - out_interp = run!(sim_interp, meteo5, executor=SequentialEx()) - out_interp_df = convert_outputs(out_interp, DataFrame) - @test out_interp_df[:Leaf][:, :YI] == [1.0, 1.0, 3.0, 4.0, 5.0] - - # Expectation 10: Aggregate policy computes mean over the consumer window. - # Source runs every step with XA=[1,2,3,4]. - # Consumer runs on t=1,3 (ClockSpec(2,1)): - # - at t=1: window [0,1] => mean([1]) = 1 - # - at t=3: window [2,3] => mean([2,3]) = 2.5 - # Output YA over time is therefore [1, 1, 2.5, 2.5]. - agg_counter = Ref(0) - mapping_agg = ModelMapping( - :Leaf => ( - ModelSpec(MRAggSourceModel(agg_counter)) |> TimeStepModel(1.0), - ModelSpec(MRAggConsumerModel()) |> - TimeStepModel(ClockSpec(2.0, 1.0)) |> - InputBindings(; XA=(process=:mraggsource, var=:XA, policy=Aggregate())), - ), - ) - sim_agg = PlantSimEngine.GraphSimulation(mtg, mapping_agg, nsteps=4, check=true, outputs=Dict(:Leaf => (:YA,))) - out_agg = run!(sim_agg, meteo4, executor=SequentialEx()) - out_agg_df = convert_outputs(out_agg, DataFrame) - @test out_agg_df[:Leaf][:, :YA] == [1.0, 1.0, 2.5, 2.5] - @test status(sim_agg)[:Leaf][1].YA == 2.5 - nid_agg = node_id(status(sim_agg)[:Leaf][1].node) - key_agg = OutputKey(scope, :Leaf, nid_agg, :mraggsource, :XA) - @test haskey(sim_agg.temporal_state.streams, key_agg) - @test length(sim_agg.temporal_state.streams[key_agg]) <= 2 - @test sim_agg.temporal_state.producer_horizons[(:Leaf, :mraggsource, :XA)] == 2.0 - - # Expectation 10a: duration-aware reducers receive per-sample durations (seconds). - # Using hourly meteo rows, RadiationEnergy integrates XA values as value * 3600 s. - meteo4_hourly = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65, duration=Dates.Hour(1))], 4)) - agg_counter_duration = Ref(0) - mapping_agg_duration = ModelMapping( - :Leaf => ( - ModelSpec(MRAggSourceModel(agg_counter_duration)) |> TimeStepModel(1.0), - ModelSpec(MRAggConsumerModel()) |> - TimeStepModel(ClockSpec(2.0, 1.0)) |> - InputBindings(; XA=(process=:mraggsource, var=:XA, policy=Integrate(RadiationEnergy()))), - ), - ) - sim_agg_duration = PlantSimEngine.GraphSimulation(mtg, mapping_agg_duration, nsteps=4, check=true, outputs=Dict(:Leaf => (:YA,))) - out_agg_duration = run!(sim_agg_duration, meteo4_hourly, executor=SequentialEx()) - out_agg_duration_df = convert_outputs(out_agg_duration, DataFrame) - @test isapprox.(out_agg_duration_df[:Leaf][:, :YA], [0.0036, 0.0036, 0.018, 0.018], atol=1.0e-9) |> all - - # Expectation 10aa: custom two-argument reducers can use durations directly. - agg_counter_duration_callable = Ref(0) - mapping_agg_duration_callable = ModelMapping( - :Leaf => ( - ModelSpec(MRAggSourceModel(agg_counter_duration_callable)) |> TimeStepModel(1.0), - ModelSpec(MRAggConsumerModel()) |> - TimeStepModel(ClockSpec(2.0, 1.0)) |> - InputBindings(; XA=(process=:mraggsource, var=:XA, policy=Integrate((vals, durations) -> sum(vals .* durations)))), - ), - ) - sim_agg_duration_callable = PlantSimEngine.GraphSimulation( - mtg, - mapping_agg_duration_callable, - nsteps=4, - check=true, - outputs=Dict(:Leaf => (:YA,)) - ) - out_agg_duration_callable = run!(sim_agg_duration_callable, meteo4_hourly, executor=SequentialEx()) - out_agg_duration_callable_df = convert_outputs(out_agg_duration_callable, DataFrame) - @test out_agg_duration_callable_df[:Leaf][:, :YA] == [3600.0, 3600.0, 18000.0, 18000.0] - - # Expectation 10b: inferred bindings use producer output_policy by default. - # Source XT=[1,2,3,4], consumer runs at t=1,3 and has no explicit InputBindings. - # output_policy(XT=Aggregate()) drives YT to [1,1,2.5,2.5]. - trait_counter = Ref(0) - mapping_trait_default = ModelMapping( - :Leaf => ( - ModelSpec(MRTraitAggSourceModel(trait_counter)) |> TimeStepModel(1.0), - ModelSpec(MRTraitAggConsumerModel()) |> TimeStepModel(ClockSpec(2.0, 1.0)), - ), - ) - sim_trait_default = PlantSimEngine.GraphSimulation(mtg, mapping_trait_default, nsteps=4, check=true, outputs=Dict(:Leaf => (:YT,))) - out_trait_default = run!(sim_trait_default, meteo4, executor=SequentialEx()) - out_trait_default_df = convert_outputs(out_trait_default, DataFrame) - @test out_trait_default_df[:Leaf][:, :YT] == [1.0, 1.0, 2.5, 2.5] - @test input_bindings(PlantSimEngine.get_model_specs(sim_trait_default)[:Leaf][:mrtraitaggconsumer]).XT.policy isa Aggregate - - # Expectation 10bb: output_policy is consumed lazily per bound variable. - # Here source policy declares V1 and V2 as Aggregate, but only V1 is consumed. - # Runtime must allocate/integrate only V1 (no stream/horizon for V2). - dual_trait_counter = Ref(0) - mapping_trait_partial_use = ModelMapping( - :Leaf => ( - ModelSpec(MRTraitDualAggSourceModel(dual_trait_counter)) |> TimeStepModel(1.0), - ModelSpec(MRUsesV1ConsumerModel()) |> TimeStepModel(ClockSpec(2.0, 1.0)), - ), - ) - sim_trait_partial_use = PlantSimEngine.GraphSimulation(mtg, mapping_trait_partial_use, nsteps=4, check=true, outputs=Dict(:Leaf => (:YV1,))) - out_trait_partial_use = run!(sim_trait_partial_use, meteo4, executor=SequentialEx()) - out_trait_partial_use_df = convert_outputs(out_trait_partial_use, DataFrame) - @test out_trait_partial_use_df[:Leaf][:, :YV1] == [1.0, 1.0, 2.5, 2.5] - spec_trait_partial_use = PlantSimEngine.get_model_specs(sim_trait_partial_use)[:Leaf][:mrusesvconsumer] - @test input_bindings(spec_trait_partial_use).V1.policy isa Aggregate - @test sim_trait_partial_use.temporal_state.producer_horizons[(:Leaf, :mrtraitdualaggsource, :V1)] == 2.0 - @test !haskey(sim_trait_partial_use.temporal_state.producer_horizons, (:Leaf, :mrtraitdualaggsource, :V2)) - nid_trait_partial_use = node_id(status(sim_trait_partial_use)[:Leaf][1].node) - key_trait_v1 = OutputKey(scope, :Leaf, nid_trait_partial_use, :mrtraitdualaggsource, :V1) - key_trait_v2 = OutputKey(scope, :Leaf, nid_trait_partial_use, :mrtraitdualaggsource, :V2) - @test haskey(sim_trait_partial_use.temporal_state.streams, key_trait_v1) - @test !haskey(sim_trait_partial_use.temporal_state.streams, key_trait_v2) - - # Expectation 10bc: user bindings can override and complement trait policies. - # Trait provides Aggregate for V1 and V2. - # User overrides V1 to Integrate and adds V1_max as Aggregate(MaxReducer()). - dual_trait_counter_priority = Ref(0) - mapping_trait_user_priority = ModelMapping( - :Leaf => ( - ModelSpec(MRTraitDualAggSourceModel(dual_trait_counter_priority)) |> TimeStepModel(1.0), - ModelSpec(MRDualPolicyConsumerModel()) |> - TimeStepModel(ClockSpec(2.0, 1.0)) |> - InputBindings( - ; - V1=(process=:mrtraitdualaggsource, var=:V1, policy=Integrate()), - V1_max=(process=:mrtraitdualaggsource, var=:V1, policy=Aggregate(MaxReducer())), - ), - Status(V1_max=0.0), - ), - ) - sim_trait_user_priority = PlantSimEngine.GraphSimulation( - mtg, - mapping_trait_user_priority, - nsteps=4, - check=true, - outputs=Dict(:Leaf => (:YV1, :YV2, :YV1_max)) - ) - out_trait_user_priority = run!(sim_trait_user_priority, meteo4, executor=SequentialEx()) - out_trait_user_priority_df = convert_outputs(out_trait_user_priority, DataFrame) - @test out_trait_user_priority_df[:Leaf][:, :YV1] == [1.0, 1.0, 5.0, 5.0] - @test out_trait_user_priority_df[:Leaf][:, :YV2] == [101.0, 101.0, 102.5, 102.5] - @test out_trait_user_priority_df[:Leaf][:, :YV1_max] == [1.0, 1.0, 3.0, 3.0] - spec_trait_user_priority = PlantSimEngine.get_model_specs(sim_trait_user_priority)[:Leaf][:mrdualpolicyconsumer] - @test input_bindings(spec_trait_user_priority).V1.policy isa Integrate - @test input_bindings(spec_trait_user_priority).V2.policy isa Aggregate - @test input_bindings(spec_trait_user_priority).V1_max.policy isa Aggregate - @test input_bindings(spec_trait_user_priority).V1_max.policy.reducer isa MaxReducer - - # Expectation 10c: explicit InputBindings policy overrides producer output_policy. - trait_counter_override = Ref(0) - mapping_trait_override = ModelMapping( - :Leaf => ( - ModelSpec(MRTraitAggSourceModel(trait_counter_override)) |> TimeStepModel(1.0), - ModelSpec(MRTraitAggConsumerModel()) |> - TimeStepModel(ClockSpec(2.0, 1.0)) |> - InputBindings(; XT=(process=:mrtraitaggsource, var=:XT, policy=Integrate())), - ), - ) - sim_trait_override = PlantSimEngine.GraphSimulation(mtg, mapping_trait_override, nsteps=4, check=true, outputs=Dict(:Leaf => (:YT,))) - out_trait_override = run!(sim_trait_override, meteo4, executor=SequentialEx()) - out_trait_override_df = convert_outputs(out_trait_override, DataFrame) - @test out_trait_override_df[:Leaf][:, :YT] == [1.0, 1.0, 5.0, 5.0] - - # Expectation 11: parameterized Aggregate reducer is applied per window. - # Source XA=[1,2,3,4], consumer runs at t=1,3 with reducer=MaxReducer(). - # YA over time is [1,1,3,3]. - agg_counter_max = Ref(0) - mapping_agg_max = ModelMapping( - :Leaf => ( - ModelSpec(MRAggSourceModel(agg_counter_max)) |> TimeStepModel(1.0), - ModelSpec(MRAggConsumerModel()) |> - TimeStepModel(ClockSpec(2.0, 1.0)) |> - InputBindings(; XA=(process=:mraggsource, var=:XA, policy=Aggregate(MaxReducer()))), - ), - ) - sim_agg_max = PlantSimEngine.GraphSimulation(mtg, mapping_agg_max, nsteps=4, check=true, outputs=Dict(:Leaf => (:YA,))) - out_agg_max = run!(sim_agg_max, meteo4, executor=SequentialEx()) - out_agg_max_df = convert_outputs(out_agg_max, DataFrame) - @test out_agg_max_df[:Leaf][:, :YA] == [1.0, 1.0, 3.0, 3.0] - - # Expectation 12: parameterized Integrate reducer (callable) is applied per window. - # Source XA=[1,2,3,4], consumer runs at t=1,3 with reducer=max-min. - # YA over time is [0,0,1,1]. - agg_counter_callable = Ref(0) - mapping_integrate_callable = ModelMapping( - :Leaf => ( - ModelSpec(MRAggSourceModel(agg_counter_callable)) |> TimeStepModel(1.0), - ModelSpec(MRAggConsumerModel()) |> - TimeStepModel(ClockSpec(2.0, 1.0)) |> - InputBindings(; XA=(process=:mraggsource, var=:XA, policy=Integrate(vals -> maximum(vals) - minimum(vals)))), - ), - ) - sim_integrate_callable = PlantSimEngine.GraphSimulation(mtg, mapping_integrate_callable, nsteps=4, check=true, outputs=Dict(:Leaf => (:YA,))) - out_integrate_callable = run!(sim_integrate_callable, meteo4, executor=SequentialEx()) - out_integrate_callable_df = convert_outputs(out_integrate_callable, DataFrame) - @test out_integrate_callable_df[:Leaf][:, :YA] == [0.0, 0.0, 1.0, 1.0] - - # Expectation 13: Interpolate policy supports hold mode and hold extrapolation. - # Source runs at t=1,3,5 with values 1,3,5. Consumer runs each step. - # With Interpolate(mode=:hold, extrapolation=:hold), YI is [1,1,3,3,5,5]. - interp_counter_hold = Ref(0) - mapping_interp_hold = ModelMapping( - :Leaf => ( - ModelSpec(MRInterpSourceModel(interp_counter_hold)) |> TimeStepModel(ClockSpec(2.0, 1.0)), - ModelSpec(MRInterpConsumerModel()) |> - TimeStepModel(1.0) |> - InputBindings(; XI=(process=:mrinterpsource, var=:XI, policy=Interpolate(; mode=:hold, extrapolation=:hold))), - ), - ) - meteo6 = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65)], 6)) - sim_interp_hold = PlantSimEngine.GraphSimulation(mtg, mapping_interp_hold, nsteps=6, check=true, outputs=Dict(:Leaf => (:YI,))) - out_interp_hold = run!(sim_interp_hold, meteo6, executor=SequentialEx()) - out_interp_hold_df = convert_outputs(out_interp_hold, DataFrame) - @test out_interp_hold_df[:Leaf][:, :YI] == [1.0, 1.0, 3.0, 3.0, 5.0, 5.0] - - # Expectation 14: daily producer to hourly consumer within same day uses hold-last. - # Source runs at t=1 and t=25 (ClockSpec(24,1)), consumer runs every step. - # YD should stay at 1 for t=1..24, then switch to 2 at t=25. - daily_counter = Ref(0) - mapping_daily_hourly = ModelMapping( - :Leaf => ( - ModelSpec(MRDailySourceModel(daily_counter)) |> TimeStepModel(ClockSpec(24.0, 1.0)), - ModelSpec(MRHourlyFromDailyConsumerModel()) |> - TimeStepModel(1.0) |> - InputBindings(; XD=(process=:mrdailysource, var=:XD)), - ), - ) - meteo26 = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65)], 26)) - sim_daily_hourly = PlantSimEngine.GraphSimulation(mtg, mapping_daily_hourly, nsteps=26, check=true, outputs=Dict(:Leaf => (:YD,))) - out_daily_hourly = run!(sim_daily_hourly, meteo26, executor=SequentialEx()) - out_daily_hourly_df = convert_outputs(out_daily_hourly, DataFrame) - @test out_daily_hourly_df[:Leaf][1:24, :YD] == fill(1.0, 24) - @test out_daily_hourly_df[:Leaf][25:26, :YD] == [2.0, 2.0] - @test sim_daily_hourly.temporal_state.last_run[ModelKey(scope, :Leaf, :mrdailysource)] == 25.0 - @test sim_daily_hourly.temporal_state.last_run[ModelKey(scope, :Leaf, :mrhourlyfromdailyconsumer)] == 26.0 - - # Expectation 15: period-based timestep uses timeline base-step conversion. - # Meteo has duration=Hour(1), source uses Day(1) => runs on t=1 and t=25. - daily_counter_period = Ref(0) - mapping_daily_period = ModelMapping( - :Leaf => ( - ModelSpec(MRDailySourceModel(daily_counter_period)) |> TimeStepModel(Dates.Day(1)), - ModelSpec(MRHourlyFromDailyConsumerModel()) |> - TimeStepModel(1.0) |> - InputBindings(; XD=(process=:mrdailysource, var=:XD)), - ), - ) - meteo_hourly = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65, duration=Dates.Hour(1))], 26)) - sim_daily_period = PlantSimEngine.GraphSimulation(mtg, mapping_daily_period, nsteps=26, check=true, outputs=Dict(:Leaf => (:YD,))) - out_daily_period = run!(sim_daily_period, meteo_hourly, executor=SequentialEx()) - out_daily_period_df = convert_outputs(out_daily_period, DataFrame) - @test out_daily_period_df[:Leaf][1:24, :YD] == fill(1.0, 24) - @test out_daily_period_df[:Leaf][25:26, :YD] == [2.0, 2.0] - @test sim_daily_period.temporal_state.last_run[ModelKey(scope, :Leaf, :mrdailysource)] == 25.0 - - # Expectation 15b: meteo duration can be a CompoundPeriod. - # With duration=CompoundPeriod(Minute(30)), Day(1) runs at t=1 and t=49. - daily_counter_compound = Ref(0) - mapping_daily_compound = ModelMapping( - :Leaf => ( - ModelSpec(MRDailySourceModel(daily_counter_compound)) |> TimeStepModel(Dates.Day(1)), - ModelSpec(MRHourlyFromDailyConsumerModel()) |> - TimeStepModel(1.0) |> - InputBindings(; XD=(process=:mrdailysource, var=:XD)), - ), - ) - meteo_halfhourly = Weather( - repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65, duration=Dates.CompoundPeriod(Dates.Minute(30)))], 50) - ) - sim_daily_compound = PlantSimEngine.GraphSimulation(mtg, mapping_daily_compound, nsteps=50, check=true, outputs=Dict(:Leaf => (:YD,))) - out_daily_compound = run!(sim_daily_compound, meteo_halfhourly, executor=SequentialEx()) - out_daily_compound_df = convert_outputs(out_daily_compound, DataFrame) - @test out_daily_compound_df[:Leaf][1:48, :YD] == fill(1.0, 48) - @test out_daily_compound_df[:Leaf][49:50, :YD] == [2.0, 2.0] - @test sim_daily_compound.temporal_state.last_run[ModelKey(scope, :Leaf, :mrdailysource)] == 49.0 - - # Expectation 15c: missing meteo duration is rejected. - mapping_no_duration = ModelMapping( - :Leaf => ( - ModelSpec(MRDailySourceModel(Ref(0))) |> TimeStepModel(Dates.Day(1)), - ), - ) - sim_no_duration = PlantSimEngine.GraphSimulation(mtg, mapping_no_duration, nsteps=1, check=true, outputs=Dict(:Leaf => (:XD,))) - meteo_table_no_duration = DataFrame(T=[20.0], Wind=[1.0], Rh=[0.65]) - @test_throws "Missing required `duration`" run!(sim_no_duration, meteo_table_no_duration, executor=SequentialEx()) - - # Expectation 15c-bis: all meteo rows are validated for duration. - meteo_table_partial_duration = DataFrame( - T=[20.0, 21.0], - Wind=[1.0, 1.0], - Rh=[0.65, 0.66], - duration=Any[Dates.Hour(1), missing], - ) - @test_throws "meteo row 2" run!(sim_no_duration, meteo_table_partial_duration, executor=SequentialEx()) - - meteo_table_bad_duration_type = DataFrame(T=[20.0], Wind=[1.0], Rh=[0.65], duration=["1h"]) - @test_throws "Expected a positive Real" run!(sim_no_duration, meteo_table_bad_duration_type, executor=SequentialEx()) - - # Expectation 15d: non-positive meteo duration is rejected. - meteo_zero_duration = Weather([Atmosphere(T=20.0, Wind=1.0, Rh=0.65, duration=Dates.Second(0))]) - @test_throws "strictly positive" run!(sim_no_duration, meteo_zero_duration, executor=SequentialEx()) - - # Expectation 16: model timesteps shorter than meteo base step are rejected. - mapping_substep_period = ModelMapping( - :Leaf => ( - ModelSpec(MRDailySourceModel(Ref(0))) |> TimeStepModel(Dates.Minute(30)), - ), - ) - sim_substep_period = PlantSimEngine.GraphSimulation(mtg, mapping_substep_period, nsteps=26, check=true, outputs=Dict(:Leaf => (:XD,))) - @test_throws "shorter than simulation base step" run!(sim_substep_period, meteo_hourly, executor=SequentialEx()) - - # Expectation 17: unset timestep uses meteo base-step (preferred hint is informational), - # while explicit TimeStepModel keeps user-selected clock. - range_counter_a = Ref(0) - range_counter_b = Ref(0) - range_counter_forced = Ref(0) - mapping_timestep_hints = ModelMapping( - :Leaf => ( - ModelSpec(MRRangeHintAModel(range_counter_a)), - ModelSpec(MRRangeHintBModel(range_counter_b)), - ModelSpec(MRRangeHintForcedModel(range_counter_forced)) |> TimeStepModel(Dates.Hour(2)), - ), - ) - meteo8h = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65, duration=Dates.Hour(1))], 8)) - sim_timestep_hints = PlantSimEngine.GraphSimulation(mtg, mapping_timestep_hints, nsteps=8, check=true, outputs=Dict(:Leaf => (:XA, :XB, :XF))) - run!(sim_timestep_hints, meteo8h, executor=SequentialEx()) - specs_hints = PlantSimEngine.get_model_specs(sim_timestep_hints)[:Leaf] - @test isnothing(PlantSimEngine.timestep(specs_hints[:mrrangehinta])) - @test isnothing(PlantSimEngine.timestep(specs_hints[:mrrangehintb])) - @test PlantSimEngine.timestep(specs_hints[:mrrangehintforced]) == Dates.Hour(2) - @test status(sim_timestep_hints)[:Leaf][1].XA == 8.0 - @test status(sim_timestep_hints)[:Leaf][1].XB == 8.0 - @test status(sim_timestep_hints)[:Leaf][1].XF == 4.0 - - io_hints = IOBuffer() - explained_hints = PlantSimEngine.explain_model_specs(sim_timestep_hints; io=io_hints) - explain_hints_txt = String(take!(io_hints)) - @test any(r -> r.process == :mrrangehinta && isnothing(r.timestep), explained_hints) - @test occursin("meteo base step at runtime", explain_hints_txt) - @test occursin("Leaf/mrrangehinta", explain_hints_txt) - - # Expectation 17b: required timestep bounds are enforced for meteo-derived clocks. - strict_counter = Ref(0) - mapping_timestep_hints_strict = ModelMapping( - :Leaf => ( - ModelSpec(MRRangeHintStrictModel(strict_counter)), - ), - ) - sim_timestep_hints_strict = PlantSimEngine.GraphSimulation(mtg, mapping_timestep_hints_strict, nsteps=8, check=true, outputs=Dict(:Leaf => (:XS,))) - err_strict = try - run!(sim_timestep_hints_strict, meteo8h, executor=SequentialEx()) - nothing - catch err - err - end - @test err_strict isa Exception - @test occursin("outside `timestep_hint.required=", sprint(showerror, err_strict)) - - # Expectation 17c: warn if no model runs at meteo base timestep. - no_base_counter = Ref(0) - mapping_no_base_dt = ModelMapping( - :Leaf => ( - ModelSpec(MRClockSourceModel(no_base_counter)) |> TimeStepModel(ClockSpec(2.0, 1.0)), - ), - ) - sim_no_base_dt = PlantSimEngine.GraphSimulation(mtg, mapping_no_base_dt, nsteps=4, check=true, outputs=Dict(:Leaf => (:X,))) - @test_logs (:warn, r"No model runs at the meteo base timestep") run!(sim_no_base_dt, meteo4, executor=SequentialEx()) - - if _HAS_METEO_SAMPLER_API - # Expectation 18: meteo is sampled at model clock using default weather aggregation. - mapping_meteo_default = ModelMapping( - :Leaf => ( - ModelSpec(MRMeteoDailyConsumerModel()) |> - TimeStepModel(ClockSpec(2.0, 1.0)), - ), - ) - meteo_mr = Weather([ - Atmosphere(T=10.0, Wind=1.0, Rh=0.50, P=100.0, Ri_SW_f=100.0, duration=Dates.Hour(1), custom_var=1.0), - Atmosphere(T=20.0, Wind=1.0, Rh=0.60, P=100.0, Ri_SW_f=200.0, duration=Dates.Hour(1), custom_var=2.0), - Atmosphere(T=30.0, Wind=1.0, Rh=0.70, P=100.0, Ri_SW_f=300.0, duration=Dates.Hour(1), custom_var=3.0), - Atmosphere(T=40.0, Wind=1.0, Rh=0.80, P=100.0, Ri_SW_f=400.0, duration=Dates.Hour(1), custom_var=4.0), - ]) - sim_meteo_default = PlantSimEngine.GraphSimulation(mtg, mapping_meteo_default, nsteps=4, check=true, outputs=Dict(:Leaf => (:MT, :MTmin, :MTmax, :MRh, :MSW, :MSWq))) - out_meteo_default = run!(sim_meteo_default, meteo_mr, executor=SequentialEx()) - out_meteo_default_df = convert_outputs(out_meteo_default, DataFrame) - @test out_meteo_default_df[:Leaf][:, :MT] == [10.0, 10.0, 25.0, 25.0] - @test out_meteo_default_df[:Leaf][:, :MTmin] == [10.0, 10.0, 20.0, 20.0] - @test out_meteo_default_df[:Leaf][:, :MTmax] == [10.0, 10.0, 30.0, 30.0] - @test out_meteo_default_df[:Leaf][:, :MRh] == [0.5, 0.5, 0.65, 0.65] - @test out_meteo_default_df[:Leaf][:, :MSW] == [100.0, 100.0, 250.0, 250.0] - @test isapprox(out_meteo_default_df[:Leaf][:, :MSWq][1], 0.36; atol=1.0e-9) - @test isapprox(out_meteo_default_df[:Leaf][:, :MSWq][3], 1.8; atol=1.0e-9) - - # Expectation 18b: MTG + mapping entrypoint preserves multi-rate meteo sampling. - out_meteo_default_mtg = run!( - mtg, - mapping_meteo_default, - meteo_mr; - executor=SequentialEx(), - tracked_outputs=Dict(:Leaf => (:MT, :MTmin, :MTmax, :MRh, :MSW, :MSWq)), - ) - out_meteo_default_mtg_df = convert_outputs(out_meteo_default_mtg, DataFrame) - @test out_meteo_default_mtg_df[:Leaf][:, :MT] == [10.0, 10.0, 25.0, 25.0] - @test out_meteo_default_mtg_df[:Leaf][:, :MTmin] == [10.0, 10.0, 20.0, 20.0] - @test out_meteo_default_mtg_df[:Leaf][:, :MTmax] == [10.0, 10.0, 30.0, 30.0] - @test out_meteo_default_mtg_df[:Leaf][:, :MRh] == [0.5, 0.5, 0.65, 0.65] - @test out_meteo_default_mtg_df[:Leaf][:, :MSW] == [100.0, 100.0, 250.0, 250.0] - @test isapprox(out_meteo_default_mtg_df[:Leaf][:, :MSWq][1], 0.36; atol=1.0e-9) - @test isapprox(out_meteo_default_mtg_df[:Leaf][:, :MSWq][3], 1.8; atol=1.0e-9) - - # Expectation 19: meteo bindings allow custom reducers and variable remapping. - mapping_meteo_custom = ModelMapping( - :Leaf => ( - ModelSpec(MRMeteoCustomConsumerModel()) |> - TimeStepModel(ClockSpec(2.0, 1.0)) |> - MeteoBindings( - ; - Ri_SW_f=RadiationEnergy(), - custom_peak=(source=:custom_var, reducer=MaxReducer()), - ), - ), - ) - sim_meteo_custom = PlantSimEngine.GraphSimulation(mtg, mapping_meteo_custom, nsteps=4, check=true, outputs=Dict(:Leaf => (:MRQ, :MCV))) - out_meteo_custom = run!(sim_meteo_custom, meteo_mr, executor=SequentialEx()) - out_meteo_custom_df = convert_outputs(out_meteo_custom, DataFrame) - @test isapprox.(out_meteo_custom_df[:Leaf][:, :MRQ], [0.36, 0.36, 1.8, 1.8], atol=1.0e-9) |> all - @test out_meteo_custom_df[:Leaf][:, :MCV] == [1.0, 1.0, 3.0, 3.0] - - # Expectation 20: meteo hints infer default bindings/window when ModelSpec does not provide them. - meteo_hint_rows = Weather([ - Atmosphere( - date=DateTime(2025, 2, 1, h - 1, 0, 0), - duration=Dates.Hour(1), - T=float(h), - Wind=1.0, - Rh=0.50, - P=100.0, - Ri_SW_f=100.0, - ) - for h in 1:24 - ]) - mapping_meteo_hint = ModelMapping( - :Leaf => ( - ModelSpec(MRMeteoHintConsumerModel()) |> TimeStepModel(Dates.Day(1)), - ), - ) - sim_meteo_hint = PlantSimEngine.GraphSimulation(mtg, mapping_meteo_hint, nsteps=24, check=true, outputs=Dict(:Leaf => (:HT, :HSWQ))) - out_meteo_hint = run!(sim_meteo_hint, meteo_hint_rows, executor=SequentialEx()) - out_meteo_hint_df = convert_outputs(out_meteo_hint, DataFrame) - spec_meteo_hint = PlantSimEngine.get_model_specs(sim_meteo_hint)[:Leaf][:mrmeteohintconsumer] - @test PlantSimEngine.timestep(spec_meteo_hint) == Dates.Day(1) - @test meteo_window(spec_meteo_hint) isa CalendarWindow - @test meteo_bindings(spec_meteo_hint).T.reducer isa MaxReducer - @test out_meteo_hint_df[:Leaf][1, :HT] == 24.0 - @test isapprox(out_meteo_hint_df[:Leaf][1, :HSWQ], 8.64; atol=1.0e-9) - - # Expectation 21: CalendarWindow(:day, :current_period) aggregates over the civil day - # (including future timesteps in the same day). - meteo_calendar = Weather(vcat( - [ - Atmosphere( - date=DateTime(2025, 1, 1, h - 1, 0, 0), - duration=Dates.Hour(1), - T=float(h), - Wind=1.0, - Rh=0.50, - P=100.0, - Ri_SW_f=100.0 - ) - for h in 1:24 - ], - [ - Atmosphere( - date=DateTime(2025, 1, 2, h - 1, 0, 0), - duration=Dates.Hour(1), - T=float(100 + h), - Wind=1.0, - Rh=0.60, - P=100.0, - Ri_SW_f=200.0 - ) - for h in 1:24 - ], - )) - - mapping_meteo_calendar_current = ModelMapping( - :Leaf => ( - ModelSpec(MRMeteoDailyConsumerModel()) |> - TimeStepModel(1.0) |> - MeteoWindow(CalendarWindow(:day; anchor=:current_period, week_start=1, completeness=:allow_partial)), - ), - ) - sim_meteo_calendar_current = PlantSimEngine.GraphSimulation(mtg, mapping_meteo_calendar_current, nsteps=48, check=true, outputs=Dict(:Leaf => (:MT, :MTmin, :MTmax, :MRh, :MSW, :MSWq))) - out_meteo_calendar_current = run!(sim_meteo_calendar_current, meteo_calendar, executor=SequentialEx()) - out_meteo_calendar_current_df = convert_outputs(out_meteo_calendar_current, DataFrame) - @test out_meteo_calendar_current_df[:Leaf][1, :MT] == 12.5 - @test out_meteo_calendar_current_df[:Leaf][10, :MT] == 12.5 - @test out_meteo_calendar_current_df[:Leaf][25, :MT] == 112.5 - @test out_meteo_calendar_current_df[:Leaf][1, :MTmin] == 1.0 - @test out_meteo_calendar_current_df[:Leaf][1, :MTmax] == 24.0 - @test out_meteo_calendar_current_df[:Leaf][25, :MTmin] == 101.0 - @test out_meteo_calendar_current_df[:Leaf][25, :MTmax] == 124.0 - @test isapprox(out_meteo_calendar_current_df[:Leaf][1, :MSWq], 8.64; atol=1.0e-9) - @test isapprox(out_meteo_calendar_current_df[:Leaf][25, :MSWq], 17.28; atol=1.0e-9) - - # Expectation 22: CalendarWindow(:day, :previous_complete_period) uses previous day. - mapping_meteo_calendar_prev = ModelMapping( - :Leaf => ( - ModelSpec(MRMeteoDailyConsumerModel()) |> - TimeStepModel(1.0) |> - MeteoWindow(CalendarWindow(:day; anchor=:previous_complete_period, week_start=1, completeness=:allow_partial)), - ), - ) - sim_meteo_calendar_prev = PlantSimEngine.GraphSimulation(mtg, mapping_meteo_calendar_prev, nsteps=48, check=true, outputs=Dict(:Leaf => (:MT, :MTmin, :MTmax, :MRh, :MSW, :MSWq))) - out_meteo_calendar_prev = run!(sim_meteo_calendar_prev, meteo_calendar, executor=SequentialEx()) - out_meteo_calendar_prev_df = convert_outputs(out_meteo_calendar_prev, DataFrame) - @test out_meteo_calendar_prev_df[:Leaf][30, :MT] == 12.5 - @test out_meteo_calendar_prev_df[:Leaf][30, :MTmin] == 1.0 - @test out_meteo_calendar_prev_df[:Leaf][30, :MTmax] == 24.0 - - # Expectation 23: strict previous-complete-period errors when unavailable. - mapping_meteo_calendar_prev_strict = ModelMapping( - :Leaf => ( - ModelSpec(MRMeteoDailyConsumerModel()) |> - TimeStepModel(1.0) |> - MeteoWindow(CalendarWindow(:day; anchor=:previous_complete_period, week_start=1, completeness=:strict)), - ), - ) - sim_meteo_calendar_prev_strict = PlantSimEngine.GraphSimulation(mtg, mapping_meteo_calendar_prev_strict, nsteps=48, check=true, outputs=Dict(:Leaf => (:MT, :MTmin, :MTmax, :MRh, :MSW, :MSWq))) - @test_throws "No period available" run!(sim_meteo_calendar_prev_strict, meteo_calendar, executor=SequentialEx()) - end - - # Expectation 24: ambiguous same-name inferred producer is rejected at initialization. - mapping_ambiguous_infer = ModelMapping( - :Leaf => ( - MRConflict1Model(), - MRConflict2Model(), - MRZConsumerModel(), - ), - ) - @test_throws "Ambiguous inferred producer for input `Z`" PlantSimEngine.GraphSimulation(mtg, mapping_ambiguous_infer, nsteps=1, check=true, outputs=Dict(:Leaf => (:ZZ,))) - - # Expectation 24a: stream-only publishers are ignored by auto input producer inference. - mapping_stream_only_infer = ModelMapping( - :Leaf => ( - MRConflict1Model(), - ModelSpec(MRConflict2Model()) |> OutputRouting(; Z=:stream_only), - MRZConsumerModel(), - ), - ) - sim_stream_only_infer = PlantSimEngine.GraphSimulation(mtg, mapping_stream_only_infer, nsteps=1, check=true, outputs=Dict(:Leaf => (:ZZ,))) - run!(sim_stream_only_infer, meteo, executor=SequentialEx()) - @test status(sim_stream_only_infer)[:Leaf][1].ZZ == 1.0 - spec_stream_only_infer = PlantSimEngine.get_model_specs(sim_stream_only_infer)[:Leaf][:mrzconsumer] - @test input_bindings(spec_stream_only_infer).Z.process == :mrconflict1 - - # Expectation 24b: cross-scale inference ignores sibling scales not on the same lineage. - mapping_lineage_infer = ModelMapping( - :Plant => ( - MRAncestorSourceModel(), - ), - :Soil => ( - MRSiblingSourceModel(), - ), - :Leaf => ( - ModelSpec(MRZConsumerModel()) |> - MultiScaleModel([:Z => (:Plant => :Z)]), - ), - ) - sim_lineage_infer = PlantSimEngine.GraphSimulation(mtg, mapping_lineage_infer, nsteps=1, check=true, outputs=Dict(:Leaf => (:ZZ,))) - run!(sim_lineage_infer, meteo, executor=SequentialEx()) - @test status(sim_lineage_infer)[:Leaf][1].ZZ == 11.0 - spec_lineage_infer = PlantSimEngine.get_model_specs(sim_lineage_infer)[:Leaf][:mrzconsumer] - @test input_bindings(spec_lineage_infer).Z.process == :mrancestorsource - @test input_bindings(spec_lineage_infer).Z.scale == :Plant - - # Expectation 24c: inferred bindings prefer same-scale producers when available. - mapping_same_scale_preferred = ModelMapping( - :Plant => ( - MRMultiScaleTSourceModel(100.0), - ), - :Internode => ( - MRMultiScaleTSourceModel(10.0), - ), - :Leaf => ( - MRMultiScaleTSourceModel(1.0), - MRLeafTTConsumerModel(), - ), - ) - sim_same_scale_preferred = PlantSimEngine.GraphSimulation( - mtg, - mapping_same_scale_preferred, - nsteps=1, - check=true, - outputs=Dict(:Leaf => (:TT, :TT_used)) - ) - run!(sim_same_scale_preferred, meteo, executor=SequentialEx()) - spec_same_scale_preferred = PlantSimEngine.get_model_specs(sim_same_scale_preferred)[:Leaf][:mrleafttconsumer] - @test input_bindings(spec_same_scale_preferred).TT.process == :mrmultiscaletsource - @test input_bindings(spec_same_scale_preferred).TT.scale == :Leaf - @test status(sim_same_scale_preferred)[:Leaf][1].TT_used == 1.0 - - # Expectation 24d: when no same-scale producer exists, repeated process names across scales require explicit scale mapping. - mapping_cross_scale_same_process_ambiguous = ModelMapping( - :Plant => ( - MRMultiScaleTSourceModel(100.0), - ), - :Internode => ( - MRMultiScaleTSourceModel(10.0), - ), - :Leaf => ( - MRLeafTTConsumerModel(), - ), - ) - @test_throws "Ambiguous inferred producer for input `TT`" PlantSimEngine.GraphSimulation( - mtg, - mapping_cross_scale_same_process_ambiguous, - nsteps=1, - check=true, - outputs=Dict(:Leaf => (:TT_used,)) - ) - - # Expectation 24e: same-rate hard dependencies are ignored for auto bindings and canonical publisher checks. - mapping_hard_same_rate = ModelMapping( - :Leaf => ( - ModelSpec(MRHardParentModel()) |> TimeStepModel(1.0), - ModelSpec(MRHardChildModel()) |> TimeStepModel(1.0), - ModelSpec(MRHardConsumerModel()) |> TimeStepModel(1.0), - ), - ) - sim_hard_same_rate = PlantSimEngine.GraphSimulation(mtg, mapping_hard_same_rate, nsteps=1, check=true, outputs=Dict(:Leaf => (:A, :B))) - run!(sim_hard_same_rate, meteo, executor=SequentialEx()) - spec_hard_same_rate = PlantSimEngine.get_model_specs(sim_hard_same_rate)[:Leaf][:mrhardconsumer] - @test input_bindings(spec_hard_same_rate).A.process == :mrhardparent - @test status(sim_hard_same_rate)[:Leaf][1].B == 5.0 - - # Expectation 24f: different-rate hard dependencies remain strict and require explicit disambiguation. - mapping_hard_different_rate = ModelMapping( - :Leaf => ( - ModelSpec(MRHardParentModel()) |> TimeStepModel(1.0), - ModelSpec(MRHardChildModel()) |> TimeStepModel(2.0), - ModelSpec(MRHardConsumerModel()) |> TimeStepModel(1.0), - ), - ) - @test_throws "Ambiguous inferred producer for input `A`" PlantSimEngine.GraphSimulation( - mtg, - mapping_hard_different_rate, - nsteps=1, - check=true, - outputs=Dict(:Leaf => (:A, :B)) - ) - - mapping_hard_different_rate_explicit = ModelMapping( - :Leaf => ( - ModelSpec(MRHardParentModel()) |> TimeStepModel(1.0), - ModelSpec(MRHardChildModel()) |> TimeStepModel(2.0), - ModelSpec(MRHardConsumerModel()) |> - TimeStepModel(1.0) |> - InputBindings(; A=(process=:mrhardparent, var=:A)), - ), - ) - sim_hard_different_rate_explicit = PlantSimEngine.GraphSimulation( - mtg, - mapping_hard_different_rate_explicit, - nsteps=1, - check=true, - outputs=Dict(:Leaf => (:A, :B)) - ) - @test_throws "Ambiguous canonical publishers" run!(sim_hard_different_rate_explicit, meteo, executor=SequentialEx()) - - # Expectation 25: missing producer remains allowed; model can rely on initialized/forced inputs. - mapping_missing_input = ModelMapping( - :Leaf => ( - MRMissingInputConsumerModel(), - Status(U=42.0), - ), - ) - sim_missing_input = PlantSimEngine.GraphSimulation(mtg, mapping_missing_input, nsteps=1, check=true, outputs=Dict(:Leaf => (:OU,))) - run!(sim_missing_input, meteo, executor=SequentialEx()) - @test status(sim_missing_input)[:Leaf][1].OU == 42.0 - - # Expectation 26: invalid mapping-level API configuration fails during GraphSimulation init. - mapping_bad_input = ModelMapping( - :Leaf => ( - MRSourceModel(), - ModelSpec(MRConsumerModel()) |> - InputBindings(; Z=(process=:mrsource, var=:S)), - ), - ) - @test_throws "declares binding for input `Z`" PlantSimEngine.GraphSimulation(mtg, mapping_bad_input, nsteps=1, check=true, outputs=Dict(:Leaf => (:B,))) - - mapping_bad_process = ModelMapping( - :Leaf => ( - ModelSpec(MRConsumerModel()) |> - InputBindings(; C=(process=:unknown_process, var=:S)), - ), - ) - @test_throws "Unknown source process `unknown_process`" PlantSimEngine.GraphSimulation(mtg, mapping_bad_process, nsteps=1, check=true, outputs=Dict(:Leaf => (:B,))) - - mapping_bad_routing = ModelMapping( - :Leaf => ( - ModelSpec(MRSourceModel()) |> - OutputRouting(; Z=:stream_only), - ), - ) - @test_throws "declares routing for output `Z`" PlantSimEngine.GraphSimulation(mtg, mapping_bad_routing, nsteps=1, check=true, outputs=Dict(:Leaf => (:S,))) - - mapping_bad_interp_mode = ModelMapping( - :Leaf => ( - MRSourceModel(), - ModelSpec(MRConsumerModel()) |> - InputBindings(; C=(process=:mrsource, var=:S, policy=Interpolate(:spline))), - ), - ) - @test_throws "Invalid interpolation mode `spline`" PlantSimEngine.GraphSimulation(mtg, mapping_bad_interp_mode, nsteps=1, check=true, outputs=Dict(:Leaf => (:B,))) - - @test_throws "Unsupported reducer value" Aggregate(:median) - - mapping_bad_period = ModelMapping( - :Leaf => ( - ModelSpec(MRDailySourceModel(Ref(0))) |> TimeStepModel(Dates.Month(1)), - ), - ) - @test_throws "non-fixed periods are not supported" PlantSimEngine.GraphSimulation(mtg, mapping_bad_period, nsteps=1, check=true, outputs=Dict(:Leaf => (:XD,))) - - mapping_bad_scope = ModelMapping( - :Leaf => ( - ModelSpec(MRSourceModel()) |> ScopeModel(:invalid_scope), - ), - ) - @test_throws "Invalid scope selector" PlantSimEngine.GraphSimulation(mtg, mapping_bad_scope, nsteps=1, check=true, outputs=Dict(:Leaf => (:S,))) - - mapping_bad_meteo = ModelMapping( - :Leaf => ( - ModelSpec(MRMeteoCustomConsumerModel()) |> - MeteoBindings(; Ri_SW_f=(source=:Ri_SW_f, badfield=:oops)), - ), - ) - @test_throws "unsupported fields" PlantSimEngine.GraphSimulation(mtg, mapping_bad_meteo, nsteps=1, check=true, outputs=Dict(:Leaf => (:MRQ,))) - - @test_throws "Unsupported MeteoBindings value" ModelMapping( - :Leaf => ( - ModelSpec(MRMeteoCustomConsumerModel()) |> - MeteoBindings(; Ri_SW_f=:radiation_energy), - ), - ) - - @test_throws "Unsupported MeteoWindow value" ModelMapping( - :Leaf => ( - ModelSpec(MRMeteoCustomConsumerModel()) |> - MeteoWindow("day"), - ), - ) - - PlantSimEngine.@process "mrbadhintmodel" verbose = false - struct MRBadHintModel <: AbstractMrbadhintmodelModel end - PlantSimEngine.inputs_(::MRBadHintModel) = NamedTuple() - PlantSimEngine.outputs_(::MRBadHintModel) = (X=-Inf,) - PlantSimEngine.run!(::MRBadHintModel, models, status, meteo, constants=nothing, extra=nothing) = (status.X = 1.0) - PlantSimEngine.timestep_hint(::Type{<:MRBadHintModel}) = "hourly" - - mapping_bad_hint = ModelMapping( - :Leaf => ( - ModelSpec(MRBadHintModel()), - ), - ) - @test_throws "Invalid `timestep_hint`" PlantSimEngine.GraphSimulation(mtg, mapping_bad_hint, nsteps=1, check=true, outputs=Dict(:Leaf => (:X,))) -end diff --git a/test/test-multirate-scaffolding.jl b/test/test-multirate-scaffolding.jl deleted file mode 100644 index 1312cd3cd..000000000 --- a/test/test-multirate-scaffolding.jl +++ /dev/null @@ -1,72 +0,0 @@ -using PlantSimEngine -using PlantSimEngine.Examples -using Test - -@testset "Multi-rate scaffolding" begin - m = Process1Model(1.0) - - clk = timespec(m) - @test clk.dt == 1.0 - @test clk.phase == 0.0 - - @test output_policy(m) == NamedTuple() - @test isnothing(timestep_hint(m)) - @test isnothing(meteo_hint(m)) - @test input_bindings(ModelSpec(m)) == NamedTuple() - @test output_routing(ModelSpec(m)) == NamedTuple() - @test model_scope(ModelSpec(m)) == :global - - mapping = Dict(:Leaf => (m,)) - resolved_specs = resolved_model_specs(mapping) - @test haskey(resolved_specs, :Leaf) - @test haskey(resolved_specs[:Leaf], :process1) - - io = IOBuffer() - explained = explain_model_specs(mapping; io=io) - explain_txt = String(take!(io)) - @test length(explained) == 1 - @test explained[1].process == :process1 - @test occursin("Resolved model specs:", explain_txt) - @test occursin("Leaf/process1", explain_txt) - @test occursin("input_bindings=", explain_txt) - - spec = ModelSpec(m) |> - TimeStepModel(24.0) |> - InputBindings(; var1=(process=:process1, var=:var3)) |> - OutputRouting(; var3=:stream_only) |> - ScopeModel(:plant) - @test PlantSimEngine.model_(spec) === m - @test PlantSimEngine.timestep(spec) == 24.0 - @test input_bindings(spec).var1.process == :process1 - @test input_bindings(spec).var1.policy isa HoldLast - @test output_routing(spec).var3 == :stream_only - @test model_scope(spec) == :plant - - mspec = ModelSpec(m) |> MultiScaleModel([:var1 => (:Leaf => :var1)]) - @test length(PlantSimEngine.get_mapped_variables(mspec)) == 1 - - ts = TemporalState() - @test isempty(ts.caches) - @test isempty(ts.last_run) - @test isempty(ts.streams) - @test isempty(ts.producer_horizons) - @test isempty(ts.export_plans) - @test isempty(ts.export_rows) - - scope = ScopeId(:global, 1) - key = OutputKey(scope, :Leaf, 7, :process1, :var3) - ts.caches[key] = HoldLastCache(1.0, 42.0) - @test ts.caches[key] isa HoldLastCache - @test ts.caches[key].v == 42.0 - - vals = [1.0, 2.0, 3.0] - durs = [1.0, 1.0, 1.0] - @test Integrate().reducer isa SumReducer - @test Aggregate().reducer isa MeanReducer - @test PlantSimEngine._window_reduce(vals, durs, Integrate()) == 6.0 - @test PlantSimEngine._window_reduce(vals, durs, Aggregate()) == 2.0 - @test PlantSimEngine._window_reduce(vals, durs, Integrate(MeanReducer())) == - PlantSimEngine._window_reduce(vals, durs, Aggregate(MeanReducer())) - @test PlantSimEngine._window_reduce(vals, durs, Integrate(SumReducer())) == - PlantSimEngine._window_reduce(vals, durs, Aggregate(SumReducer())) -end diff --git a/test/test-performance.jl b/test/test-performance.jl deleted file mode 100644 index 462615370..000000000 --- a/test/test-performance.jl +++ /dev/null @@ -1,116 +0,0 @@ - -using BenchmarkTools -using Dates - -PlantSimEngine.@process "sleep" verbose = false - -struct ToySleepModel <: AbstractSleepModel -end - -PlantSimEngine.inputs_(::ToySleepModel) = (a=-Inf,) -PlantSimEngine.outputs_(::ToySleepModel) = NamedTuple() - -function PlantSimEngine.run!(m::ToySleepModel, models, status, meteo, constants=nothing, extra=nothing) - # sleep for 0.01 seconds (not going to be perfectly accurate) - Base.sleep(0.001) -end - -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToySleepModel}) = PlantSimEngine.IsTimeStepIndependent() - -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Day) -nrows = nrow(meteo_day) - -vc = [0 for i in 1:nrows] - -mapping1 = ModelMapping(process1=ToySleepModel(), status=(a=vc,)) -mapping2 = ModelMapping(process1=ToySleepModel(), status=(a=vc,)) - -@testset begin - "Check number of threads" - nthr = Threads.nthreads() - @test nthr >= 1 - - t_seq = @benchmark run!(mapping1, meteo_day; executor=SequentialEx()) - #t_seq = run!(models1, meteo_day; executor = SequentialEx()) - med_time_seq = median(t_seq).time - - #time is in nanoseconds - @test med_time_seq > nrows * 1000000 - - t_mt = @benchmark run!(mapping2, meteo_day; executor=ThreadedEx()) - #t_mt = run!(models2, meteo_day; executor = ThreadedEx()) - med_time_mt = median(t_mt).time - - if nthr > 1 - @test med_time_mt > nrows * 1000000 / nthr - end - - # Threads sleep/wakeup scheduling overhead causing inconsistencies ? - # In any case, sometimes MT beats ST on CI runners, and the mac runner seems to return puzzling false positives - # Deactivating it for now - # TODO there is a thread discussing unreliability of the sleep() function, need to check it - - #if !Sys.isapple() - # @test abs(nthr * med_time_mt - med_time_seq) < 0.2 * med_time_seq - #end - - # unsure how to recover outputs in benchmarked expressions to compare them, rerun the functions as a workaround for now - @test run!(mapping1, meteo_day; executor=SequentialEx()) == run!(mapping2, meteo_day; executor=ThreadedEx()) -end - -# TODO make sure a mt test with nthreads == 1 also is tested and is correct -@testset "Single and multi-threaded output consistency" begin - nthr = Threads.nthreads() - @test nthr >= 1 - - using Dates - meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Day) - - mapping = ModelMapping( - ToyLAIModel(), - Beer(0.5), - status=(TT_cu=cumsum(meteo_day.TT),) - ) - - tracked_outputs = (:LAI,) - - out_seq, out_mt = run_single_and_multi_thread_modellist(mapping, tracked_outputs, meteo_day) - @test compare_outputs_modellists(out_seq, out_mt) - - mappings_single_scale, _, outs_vectors = get_modelmapping_bank() - meteos_all = get_simple_meteo_bank() - - # First meteo only has one timestep - meteos = meteos_all[2:length(meteos_all)] - - for i in 1:length(mappings_single_scale) - #i = 1 - mapping_template = mappings_single_scale[i] - outs_vector = outs_vectors[i] - for j in 1:length(meteos) - meteo = meteos[j] - for k in 1:length(outs_vector) - #k = 1 - out_tuple = outs_vector[k] - - try - mapping = deepcopy(mapping_template) - out_st, out_mt = run_single_and_multi_thread_modellist(mapping, out_tuple, meteo) - @test compare_outputs_modellists(out_st, out_mt) - catch e - #print(i," ", j, " ", k) - #println() - if isa(e, DimensionMismatch) - continue - elseif isa(e, ErrorException) - showerror(stdout, e) - @test false - else - showerror(stdout, e) - @test false - end - end - end - end - end -end diff --git a/test/test-simulation.jl b/test/test-simulation.jl deleted file mode 100644 index c1a2fb8cc..000000000 --- a/test/test-simulation.jl +++ /dev/null @@ -1,327 +0,0 @@ -@testset "Check missing model" begin - # No problem here: - @test_nowarn ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=15.0, var2=0.3) - ) - - # Missing model for process2: - @test_logs ( - :info, - "Model Process3Model from process process3 needs a model that is a subtype of Process2Model in process process2, but the process is not parameterized in the ModelMapping." - ), - ( - :info, - "Some variables must be initialized before simulation: (process3 = (:var5,),) (see `to_initialize()`)" - ) - ModelMapping( - process1=Process1Model(1.0), - process3=Process3Model(), - status=(var1=15.0, var2=0.3) - ) -end; - -@testset "Deprecated run! entrypoints" begin - models = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=15.0, var2=0.3) - ) - meteo = Atmosphere(T=20.0, Wind=1.0, Rh=0.65) - - run!(models, meteo) - @test_deprecated run!([models], meteo) - @test_throws ErrorException run!(ModelMapping("mod1" => models), meteo) - - mtg = Node(MultiScaleTreeGraph.NodeMTG("/", :Leaf, 1, 1)) - mtg[:var1] = 15.0 - mtg[:var2] = 0.3 - mapping_dict = Dict(:Leaf => (Process1Model(1.0), Process2Model(), Process3Model())) - @test_deprecated run!(mtg, mapping_dict, meteo) -end - -@testset "Removed multirate keyword for single-scale" begin - mapping = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=15.0, var2=0.3) - ) - meteo = Atmosphere(T=20.0, Wind=1.0, Rh=0.65) - - @test_throws MethodError run!(mapping, meteo; multirate=true) - @test_throws MethodError run!([mapping], meteo; multirate=true) -end - -@testset "Simulation: 1 time-step, 0 Atmosphere" begin - mapping = ModelMapping( - Process1Model(1.0); - status=(var1=15.0, var2=0.3) - ) - outputs = run!(mapping) - - vars = keys(outputs) - @test [outputs[i][1] for i in vars] == [15.0, 0.3, 5.5] -end; - - -@testset "Simulation: 1 time-step, 1 Atmosphere" begin - - status_nt = (var1=15.0, var2=0.3) - mapping = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=status_nt - ) - - meteo = Atmosphere(T=20.0, Wind=1.0, Rh=0.65) - - modellist_outputs = run!(mapping, meteo) - vars = keys(modellist_outputs) - @test [modellist_outputs[i][1] for i in vars] == [34.95, 22.0, 56.95, 15.0, 5.5, 0.3] - - @test check_multiscale_simulation_is_equivalent(mapping, meteo) -end; - -@testset "Simulation: 1 time-step, 1 Atmosphere, 2 objects" begin - mapping = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=15.0, var2=0.3) - ) - - mapping2 = ModelMapping( - process1=Process1Model(2.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=15.0, var2=0.3) - ) - - meteo = Atmosphere(T=20.0, Wind=1.0, Rh=0.65) - - @testset "simulation with an array of objects" begin - outputs_vector = run!([mapping, mapping2], meteo) - @test [outputs_vector[1][i][1] for i in keys(outputs_vector[1])] == [34.95, 22.0, 56.95, 15.0, 5.5, 0.3] - @test [outputs_vector[2][i][1] for i in keys(outputs_vector[2])] == [36.95, 26.0, 62.95, 15.0, 6.5, 0.3] - end - - @testset "simulation with a dict of objects" begin - outputs_vector = run!(Dict("mod1" => mapping, "mod2" => mapping2), meteo) - @test [outputs_vector["mod1"][1][i] for i in keys(outputs_vector["mod1"])] == [34.95, 22.0, 56.95, 15.0, 5.5, 0.3] - @test [outputs_vector["mod2"][1][i] for i in keys(outputs_vector["mod2"])] == [36.95, 26.0, 62.95, 15.0, 6.5, 0.3] - end -end; - -@testset "Simulation: 2 time-steps, 1 Atmosphere" begin - mapping = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=[15.0, 16.0], var2=0.3) - ) - - meteo = Atmosphere(T=20.0, Wind=1.0, Rh=0.65) - - outputs = run!(mapping, meteo) - vars = keys(outputs) - @test [outputs[i] for i in vars] == [ - [34.95, 35.550000000000004], - [22.0, 23.2], - [56.95, 58.75], - [15.0, 16.0], - [5.5, 5.8], - [0.3, 0.3], - ] -end; - -@testset "Simulation: 2 time-steps, 2 Atmospheres" begin - - status_nt = (var1=[15.0, 16.0], var2=0.3) - - mapping = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=status_nt - ) - - meteo = Weather( - [ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65), - Atmosphere(T=25.0, Wind=0.5, Rh=0.8) - ] - ) - - modellist_outputs = run!(mapping, meteo) - vars = keys(modellist_outputs) - @test [modellist_outputs[i] for i in vars] == [ - [34.95, 40.0], - [22.0, 23.2], - [56.95, 63.2], - [15.0, 16.0], - [5.5, 5.8], - [0.3, 0.3], - ] - - @test check_multiscale_simulation_is_equivalent(mapping, meteo) -end; - - -@testset "Simulation: 2 time-steps, 2 Atmospheres, 2 objects" begin - mapping = ModelMapping( - process1=Process1Model(1.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=[15.0, 16.0], var2=0.3) - ) - - mapping2 = ModelMapping( - process1=Process1Model(2.0), - process2=Process2Model(), - process3=Process3Model(), - status=(var1=[15.0, 16.0], var2=0.3) - ) - - meteo = Weather( - [ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65), - Atmosphere(T=25.0, Wind=0.5, Rh=0.8) - ] - ) - - @testset "simulation with an array of objects" begin - outputs_vector = run!([mapping, mapping2], meteo) - @test [outputs_vector[1][i] for i in keys(outputs_vector[1])] == [ - [34.95, 40.0], [22.0, 23.2], [56.95, 63.2], [15.0, 16.0], [5.5, 5.8], [0.3, 0.3] - ] - @test [outputs_vector[2][i] for i in keys(outputs_vector[2])] == [ - [36.95, 42.0], [26.0, 27.2], [62.95, 69.2], [15.0, 16.0], [6.5, 6.8], [0.3, 0.3] - ] - end - - @testset "simulation with a dict of objects" begin - outputs_vector = run!(Dict("mod1" => mapping, "mod2" => mapping2), meteo) - @test [[outputs_vector["mod1"][1][i], outputs_vector["mod1"][2][i]] for i in keys(outputs_vector["mod1"])] == [ - [34.95, 40.0], [22.0, 23.2], [56.95, 63.2], [15.0, 16.0], [5.5, 5.8], [0.3, 0.3] - ] - @test [[outputs_vector["mod2"][1][i], outputs_vector["mod2"][2][i]] for i in keys(outputs_vector["mod2"])] == [ - [36.95, 42.0], [26.0, 27.2], [62.95, 69.2], [15.0, 16.0], [6.5, 6.8], [0.3, 0.3] - ] - end -end; - -@testset "Simulation: 2 time-steps, 2 Atmospheres, MTG" begin - mtg = Node(MultiScaleTreeGraph.NodeMTG("/", :Plant, 1, 1)) - internode = Node(mtg, MultiScaleTreeGraph.NodeMTG("/", :Internode, 1, 2)) - leaf = Node(mtg, MultiScaleTreeGraph.NodeMTG("<", :Leaf, 1, 2)) - leaf[:var1] = [15.0, 16.0] - leaf[:var2] = 0.3 - - mapping = ModelMapping( - :Leaf => ( - Process1Model(1.0), - Process2Model(), - Process3Model() - ) - ) - - meteo = Weather( - [ - Atmosphere(T=20.0, Wind=1.0, Rh=0.65), - Atmosphere(T=25.0, Wind=0.5, Rh=0.8) - ] - ) - - # var1 is taken from the MTG attributes but is a vector instead of a scalar, expecting an error: - VERSION >= v"1.8" && @test_throws AssertionError run!(mtg, mapping, meteo) - - leaf[:var1] = 15.0 - - #out = @test_nowarn run!(mtg, mapping, meteo) - nsteps = PlantSimEngine.get_nsteps(meteo) - sim = PlantSimEngine.GraphSimulation(mtg, mapping, nsteps=nsteps, check=true) - out = @test_nowarn run!(sim, meteo) - - vars = (:var4, :var6, :var5, :var1, :var2, :var3) - @test [sim.statuses[:Leaf][1][i] for i in vars] == [ - 22.0, 61.4, 39.4, 15.0, 0.3, 5.5 - ] -end; - - -@testset "Meteo+ModelMapping/mapping+outputs combos either valid or different status vector size vs meteo length either run successfully or return a DimensionMisMatch" begin - verbose = false # set to true to print the indices of the combinations that fail - meteos = get_simple_meteo_bank() - mappings_single_scale, _, outputs_tuples_vectors = get_modelmapping_bank() - - for i in 1:length(mappings_single_scale) - # i = 3 - mapping_template = mappings_single_scale[i] - outs_vector = outputs_tuples_vectors[i] - - for j in 1:length(meteos) - # j = 1 - meteo = meteos[j] - for k in 1:length(outs_vector) - # k = 7 - out_tuple = outs_vector[k] - @test try - mapping = deepcopy(mapping_template) - outs_modellist = run!(mapping, meteo; tracked_outputs=out_tuple) - true - catch e - verbose && print(i, " ", j, " ", k) - verbose && println() - if isa(e, DimensionMismatch) - true - elseif isa(e, ErrorException) - showerror(stdout, e) - false - else - showerror(stdout, e) - false - end - end - end - end - end - - mtgs, mappings, outs_tuples_vectors_mappings = get_simple_mapping_bank() - - for i in 1:length(mappings) - # i = 1 - mapping = mappings[i] - outs_vector = outs_tuples_vectors_mappings[i] - - for j in 1:length(meteos) - # j = 1 - meteo = meteos[j] - for k in 1:length(outs_vector) - # k = 4 - out_tuple = outs_vector[k] - - mtg = deepcopy(mtgs[i]) - try - outs_multiscale = run!(mtg, mapping, meteo; tracked_outputs=out_tuple) - @test true - catch e - verbose && print(i, " ", j, " ", k) - verbose && println() - if isa(e, DimensionMismatch) - @test true - #elseif isa(e, ErrorException) - else - #@enter outs_multiscale = run!(mtg, mapping, meteo; tracked_outputs=out_tuple) - showerror(stdout, e) - @test false - end - end - end - end - end -end diff --git a/test/test-toy_models.jl b/test/test-toy_models.jl index ad887e590..b929d71f1 100644 --- a/test/test-toy_models.jl +++ b/test/test-toy_models.jl @@ -1,106 +1,56 @@ -meteo_day = CSV.read(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), DataFrame, header=18) +using Dates -# Note (smack) : The first test's behaviour is weird to me, because there is an [Info :] that correctly indicates -# :LAI is not initialised, yet @test_nowarn doesn't capture it. I'm not sure what the intended test was, between 'Info' and 'Warn' -@testset "ToyLAIModel" begin - @test_nowarn ModelMapping(ToyLAIModel()) - @test_nowarn ModelMapping(ToyLAIModel(); status=(TT_cu=10,)) - @test_nowarn ModelMapping( - ToyLAIModel(); - status=(TT_cu=cumsum(meteo_day.TT),), - ) - - mapping = ModelMapping( - ToyLAIModel(); - status=(TT_cu=cumsum(meteo_day.TT),), +function toy_scene(status, models...; environment=nothing) + applications = map(models) do model + ModelSpec(model) |> AppliesTo(One(scale=:Plant)) + end + CompositeModel( + Object(:plant; scale=:Plant, kind=:plant, status=status); + applications=Tuple(applications), + environment=environment, ) - - outputs = @test_nowarn run!(mapping) - - @test outputs[:TT_cu] == cumsum(meteo_day.TT) - @test outputs[:LAI][begin] ≈ 0.00554987593080316 - @test outputs[:LAI][end] ≈ 0.0 end -@testset "ToyLAIModel+Beer" begin - mapping = ModelMapping( - ToyLAIModel(), - Beer(0.5), - status=(TT_cu=cumsum(meteo_day.TT),) +@testset "Toy models through CompositeModel" begin + lai_scene = toy_scene(Status(TT_cu=900.0), ToyLAIModel()) + run!(lai_scene) + lai_status = only(model_objects(lai_scene; scale=:Plant)).status + @test 0.0 < lai_status.LAI < 8.0 + + meteo = Atmosphere( + T=20.0, + Wind=1.0, + P=101.3, + Rh=0.65, + Ri_PAR_f=300.0, + duration=Hour(1), ) + coupled = toy_scene(Status(TT_cu=900.0), ToyLAIModel(), Beer(0.5); environment=meteo) + run!(coupled) + coupled_status = only(model_objects(coupled; scale=:Plant)).status + @test coupled_status.aPPFD > 0.0 - outputs = run!(mapping, meteo_day) - - @test mean(outputs[:aPPFD]) ≈ 9.511021781482347 - @test mean(outputs[:LAI]) ≈ 1.098492557536525 -end - - -@testset "ToyRUEGrowthModel" begin - rue = 0.3 - @test_nowarn ModelMapping(ToyRUEGrowthModel(rue)) - @test_nowarn ModelMapping(ToyRUEGrowthModel(rue); status=(aPPFD=[10.0, 30.0, 25.0],)) - - # One time step: - mapping = ModelMapping(ToyRUEGrowthModel(rue); status=(aPPFD=30.0,)) - - outputs = run!(mapping, executor=SequentialEx()) - @test outputs[:biomass][1] ≈ rue * 30.0 - - # Several time steps: - aPPFD = [10.0, 30.0, 25.0] - mapping = ModelMapping(ToyRUEGrowthModel(rue); status=(aPPFD=aPPFD,)) - - outputs = run!(mapping, executor=SequentialEx()) - @test outputs[:biomass] ≈ cumsum(rue * aPPFD) -end - -@testset "ToyAssimGrowthModel" begin - @test_nowarn ModelMapping(ToyAssimGrowthModel()) - @test_nowarn ModelMapping(ToyAssimGrowthModel(); status=(carbon_assimilation=[10.0, 30.0, 25.0],)) - - # Uninitialized: - to_init_uninitialized = to_initialize(ModelMapping(ToyAssimGrowthModel())) - if to_init_uninitialized isa AbstractDict - @test haskey(to_init_uninitialized, :Default) - @test :aPPFD in to_init_uninitialized[:Default] - else - @test :growth in keys(to_init_uninitialized) - @test :aPPFD in to_init_uninitialized[:growth] - end - - # One time step: - mapping = ModelMapping(ToyAssimGrowthModel(); status=(aPPFD=30.0,)) - - @test isempty(to_initialize(mapping)) - - outputs = run!(mapping) - @test outputs[:biomass] ≈ [4.5] - - # Several time steps: - mapping = ModelMapping(ToyAssimGrowthModel(); status=(aPPFD=[10.0, 30.0, 25.0],)) - - outputs = run!(mapping) - @test outputs[:biomass] ≈ cumsum(outputs[:biomass_increment]) - @test outputs[:biomass_increment] ≈ [0.8333333333333334, 4.5, 3.5833333333333335] -end - -@testset "ToyLAIModel+Beer+ToyRUEGrowthModel" begin rue = 0.3 - mapping = ModelMapping( + rue_scene = toy_scene(Status(aPPFD=30.0), ToyRUEGrowthModel(rue)) + run!(rue_scene) + rue_status = only(model_objects(rue_scene; scale=:Plant)).status + @test rue_status.biomass ≈ rue * 30.0 + + assimilation_scene = toy_scene(Status(aPPFD=30.0), ToyAssimGrowthModel()) + run!(assimilation_scene) + assimilation_status = only(model_objects(assimilation_scene; scale=:Plant)).status + @test assimilation_status.biomass ≈ 4.5 + + full_scene = toy_scene( + Status(TT_cu=900.0), ToyLAIModel(), Beer(0.5), - ToyRUEGrowthModel(rue), - status=(TT_cu=cumsum(meteo_day.TT),), + ToyRUEGrowthModel(rue); + environment=meteo, ) - - # Match the warning on the executor, the default is ThreadedEx() but ToyRUEGrowthModel can't be run in parallel: - @test_logs (:warn, r"A parallel executor was provided") run!(mapping, meteo_day) - - # If we provide a serial executor, it works without a warning: - outputs = @test_nowarn run!(mapping, meteo_day, executor=SequentialEx()) - - @test mean(outputs[:aPPFD]) ≈ 9.511021781482347 - @test mean(outputs[:LAI]) ≈ 1.098492557536525 - @test outputs[:biomass][end] ≈ 1041.4687939085675 rtol = 1e-4 + run!(full_scene) + full_status = only(model_objects(full_scene; scale=:Plant)).status + @test full_status.LAI > 0.0 + @test full_status.aPPFD > 0.0 + @test full_status.biomass ≈ rue * full_status.aPPFD end diff --git a/test/test-unified-model-object-api.jl b/test/test-unified-model-object-api.jl new file mode 100644 index 000000000..9ec4cc252 --- /dev/null +++ b/test/test-unified-model-object-api.jl @@ -0,0 +1,3750 @@ +using Dates +using PlantSimEngine +using PlantSimEngine.Examples +using MultiScaleTreeGraph +using Test + +PlantSimEngine.@process "model_object_default_input_consumer" verbose = false + +struct ModelObjectDefaultInputConsumerModel <: AbstractModel_Object_Default_Input_ConsumerModel end + +PlantSimEngine.inputs_(::ModelObjectDefaultInputConsumerModel) = (leaf_carbon=[0.0],) +PlantSimEngine.outputs_(::ModelObjectDefaultInputConsumerModel) = (plant_carbon=0.0,) +PlantSimEngine.dep(::ModelObjectDefaultInputConsumerModel) = ( + leaf_carbon=Input(Many(scale=:Leaf, within=Subtree(), var=:leaf_carbon)), +) + +PlantSimEngine.@process "model_object_default_call_consumer" verbose = false + +struct ModelObjectDefaultCallConsumerModel <: AbstractModel_Object_Default_Call_ConsumerModel end + +PlantSimEngine.inputs_(::ModelObjectDefaultCallConsumerModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectDefaultCallConsumerModel) = (energy_balance=0.0,) +PlantSimEngine.dep(::ModelObjectDefaultCallConsumerModel) = ( + stomata=Call(scale=:Leaf, process=:stomatal_conductance), +) + +PlantSimEngine.@process "model_object_stomata" verbose = false + +struct ModelObjectStomataModel <: AbstractModel_Object_StomataModel end + +PlantSimEngine.inputs_(::ModelObjectStomataModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectStomataModel) = (gs=0.0,) + +PlantSimEngine.@process "model_object_leaf_energy" verbose = false + +struct ModelObjectLeafEnergyModel <: AbstractModel_Object_Leaf_EnergyModel end + +PlantSimEngine.inputs_(::ModelObjectLeafEnergyModel) = (leaf_areas=[0.0],) +PlantSimEngine.outputs_(::ModelObjectLeafEnergyModel) = (leaf_temperature=25.0,) +PlantSimEngine.dep(::ModelObjectLeafEnergyModel) = ( + stomata=Call(process=:model_object_stomata), +) + +PlantSimEngine.@process "model_object_carrier_consumer" verbose = false + +struct ModelObjectCarrierConsumerModel <: AbstractModel_Object_Carrier_ConsumerModel end + +PlantSimEngine.inputs_(::ModelObjectCarrierConsumerModel) = (leaf_areas=[0.0], leaf_tokens=Any[]) +PlantSimEngine.outputs_(::ModelObjectCarrierConsumerModel) = (carrier_total=0.0,) + +function PlantSimEngine.run!(::ModelObjectCarrierConsumerModel, models, status, meteo, constants=nothing, extra=nothing) + status.carrier_total = sum(status.leaf_areas) + return nothing +end + +PlantSimEngine.@process "model_object_environment_probe" verbose = false + +struct ModelObjectEnvironmentProbeModel <: AbstractModel_Object_Environment_ProbeModel end + +PlantSimEngine.inputs_(::ModelObjectEnvironmentProbeModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectEnvironmentProbeModel) = (temperature_seen=0.0,) +PlantSimEngine.meteo_inputs_(::ModelObjectEnvironmentProbeModel) = (T=0.0, CO2=0.0) + +function PlantSimEngine.run!(::ModelObjectEnvironmentProbeModel, models, status, meteo, constants=nothing, extra=nothing) + status.temperature_seen = meteo.T + return nothing +end + +PlantSimEngine.@process "model_object_environment_co2_probe" verbose = false + +struct ModelObjectEnvironmentCO2ProbeModel <: + AbstractModel_Object_Environment_Co2_ProbeModel end + +PlantSimEngine.inputs_(::ModelObjectEnvironmentCO2ProbeModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectEnvironmentCO2ProbeModel) = + (temperature_seen=0.0, co2_seen=0.0) +PlantSimEngine.meteo_inputs_(::ModelObjectEnvironmentCO2ProbeModel) = + (T=0.0, CO2=0.0) + +function PlantSimEngine.run!( + ::ModelObjectEnvironmentCO2ProbeModel, + models, + status, + meteo, + constants=nothing, + extra=nothing, +) + status.temperature_seen = meteo.T + status.co2_seen = meteo.CO2 + return nothing +end + +struct ModelObjectEnvironmentCO2HintProbeModel <: + AbstractModel_Object_Environment_Co2_ProbeModel end + +PlantSimEngine.inputs_(::ModelObjectEnvironmentCO2HintProbeModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectEnvironmentCO2HintProbeModel) = + (temperature_seen=0.0, co2_seen=0.0) +PlantSimEngine.meteo_inputs_(::ModelObjectEnvironmentCO2HintProbeModel) = + (T=0.0, CO2=0.0) +PlantSimEngine.meteo_hint(::Type{<:ModelObjectEnvironmentCO2HintProbeModel}) = + (bindings=(CO2=(source=:Ca, reducer=MeanReducer()),),) + +function PlantSimEngine.run!( + ::ModelObjectEnvironmentCO2HintProbeModel, + models, + status, + meteo, + constants=nothing, + extra=nothing, +) + status.temperature_seen = meteo.T + status.co2_seen = meteo.CO2 + return nothing +end + +struct ModelObjectAggregatedEnvironmentProbeModel <: + AbstractModel_Object_Environment_Co2_ProbeModel end + +PlantSimEngine.inputs_(::ModelObjectAggregatedEnvironmentProbeModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectAggregatedEnvironmentProbeModel) = + (temperature_seen=0.0, co2_seen=0.0) +PlantSimEngine.meteo_inputs_(::ModelObjectAggregatedEnvironmentProbeModel) = + (T=0.0, CO2=0.0) +PlantSimEngine.meteo_hint(::Type{<:ModelObjectAggregatedEnvironmentProbeModel}) = ( + bindings=( + T=(source=:T, reducer=MaxReducer()), + CO2=(source=:Ca, reducer=MeanReducer()), + ), +) + +function PlantSimEngine.run!( + ::ModelObjectAggregatedEnvironmentProbeModel, + models, + status, + meteo, + constants=nothing, + extra=nothing, +) + status.temperature_seen = meteo.T + status.co2_seen = meteo.CO2 + return nothing +end + +struct ModelObjectTemperatureOnlyProbeModel <: + AbstractModel_Object_Environment_ProbeModel end + +PlantSimEngine.inputs_(::ModelObjectTemperatureOnlyProbeModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectTemperatureOnlyProbeModel) = + (temperature_seen=0.0,) +PlantSimEngine.meteo_inputs_(::ModelObjectTemperatureOnlyProbeModel) = (T=0.0,) + +function PlantSimEngine.run!( + ::ModelObjectTemperatureOnlyProbeModel, + models, + status, + meteo, + constants=nothing, + extra=nothing, +) + status.temperature_seen = meteo.T + return nothing +end + +PlantSimEngine.@process "model_object_environment_update" verbose = false + +struct ModelObjectEnvironmentUpdateModel <: AbstractModel_Object_Environment_UpdateModel end + +PlantSimEngine.inputs_(::ModelObjectEnvironmentUpdateModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectEnvironmentUpdateModel) = (temperature_update=0.0,) +PlantSimEngine.meteo_inputs_(::ModelObjectEnvironmentUpdateModel) = (T=0.0,) +PlantSimEngine.meteo_outputs_(::ModelObjectEnvironmentUpdateModel) = (T=0.0,) + +function PlantSimEngine.run!(::ModelObjectEnvironmentUpdateModel, models, status, meteo, constants=nothing, extra=nothing) + status.temperature_update = meteo.T + 1.0 + status.T = status.temperature_update + return nothing +end + +PlantSimEngine.@process "model_object_signal_source" verbose = false + +struct ModelObjectSignalSourceModel <: AbstractModel_Object_Signal_SourceModel end + +PlantSimEngine.inputs_(::ModelObjectSignalSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectSignalSourceModel) = (signal=0.0,) + +function PlantSimEngine.run!(::ModelObjectSignalSourceModel, models, status, meteo, constants=nothing, extra=nothing) + status.signal += 1.0 + return nothing +end + +PlantSimEngine.@process "model_object_trait_clock_source" verbose = false + +struct ModelObjectTraitClockSourceModel <: AbstractModel_Object_Trait_Clock_SourceModel end + +PlantSimEngine.inputs_(::ModelObjectTraitClockSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectTraitClockSourceModel) = (signal=0.0,) +PlantSimEngine.timespec(::Type{<:ModelObjectTraitClockSourceModel}) = ClockSpec(2.0, 1.0) + +function PlantSimEngine.run!(::ModelObjectTraitClockSourceModel, models, status, meteo, constants=nothing, extra=nothing) + status.signal += 1.0 + return nothing +end + +PlantSimEngine.@process "model_object_strict_hint_source" verbose = false + +struct ModelObjectStrictHintSourceModel <: AbstractModel_Object_Strict_Hint_SourceModel end + +PlantSimEngine.inputs_(::ModelObjectStrictHintSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectStrictHintSourceModel) = (signal=0.0,) +PlantSimEngine.timestep_hint(::Type{<:ModelObjectStrictHintSourceModel}) = Dates.Day(1) + +function PlantSimEngine.run!(::ModelObjectStrictHintSourceModel, models, status, meteo, constants=nothing, extra=nothing) + status.signal += 1.0 + return nothing +end + +struct ModelObjectTimeSignalModel{T} <: AbstractModel_Object_Signal_SourceModel + prototype::T +end + +PlantSimEngine.inputs_(::ModelObjectTimeSignalModel) = NamedTuple() +PlantSimEngine.outputs_(model::ModelObjectTimeSignalModel) = (signal=zero(model.prototype),) + +function PlantSimEngine.run!(model::ModelObjectTimeSignalModel, models, status, meteo, constants=nothing, extra=nothing) + status.signal = convert(typeof(model.prototype), extra.time) + return nothing +end + +struct ModelObjectTraitPolicySignalModel <: AbstractModel_Object_Signal_SourceModel end + +PlantSimEngine.inputs_(::ModelObjectTraitPolicySignalModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectTraitPolicySignalModel) = (signal=0.0,) +PlantSimEngine.output_policy(::Type{<:ModelObjectTraitPolicySignalModel}) = (signal=Aggregate(),) + +function PlantSimEngine.run!(::ModelObjectTraitPolicySignalModel, models, status, meteo, constants=nothing, extra=nothing) + status.signal += 1.0 + return nothing +end + +struct ModelObjectParameterizedSignalModel{T} <: AbstractModel_Object_Signal_SourceModel + increment::T +end + +PlantSimEngine.inputs_(::ModelObjectParameterizedSignalModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectParameterizedSignalModel) = (signal=0.0,) + +function PlantSimEngine.run!(model::ModelObjectParameterizedSignalModel, models, status, meteo, constants=nothing, extra=nothing) + status.signal += model.increment + return nothing +end + +struct ModelObjectAlternativeSignalModel <: AbstractModel_Object_Signal_SourceModel end + +PlantSimEngine.inputs_(::ModelObjectAlternativeSignalModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectAlternativeSignalModel) = (signal=0.0,) + +function PlantSimEngine.run!( + ::ModelObjectAlternativeSignalModel, + models, + status, + meteo, + constants=nothing, + extra=nothing, +) + status.signal += 7.0 + return nothing +end + +PlantSimEngine.@process "model_object_batch_counter" verbose = false + +struct ModelObjectBatchCounterModel <: AbstractModel_Object_Batch_CounterModel + count::Base.RefValue{Int} +end + +PlantSimEngine.inputs_(::ModelObjectBatchCounterModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectBatchCounterModel) = NamedTuple() + +function PlantSimEngine.run!( + model::ModelObjectBatchCounterModel, + models, + status, + meteo, + constants=nothing, + extra=nothing, +) + model.count[] += 1 + return nothing +end + +struct ModelObjectSignalSetModel{T} <: AbstractModel_Object_Signal_SourceModel + value::T +end + +PlantSimEngine.inputs_(::ModelObjectSignalSetModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectSignalSetModel) = (signal=0.0,) + +function PlantSimEngine.run!(model::ModelObjectSignalSetModel, models, status, meteo, constants=nothing, extra=nothing) + status.signal = model.value + return nothing +end + +PlantSimEngine.@process "model_object_plant_signal_sum" verbose = false + +struct ModelObjectPlantSignalSumModel <: AbstractModel_Object_Plant_Signal_SumModel end + +PlantSimEngine.inputs_(::ModelObjectPlantSignalSumModel) = (signals=[0.0],) +PlantSimEngine.outputs_(::ModelObjectPlantSignalSumModel) = (signal_total=0.0,) + +function PlantSimEngine.run!(::ModelObjectPlantSignalSumModel, models, status, meteo, constants=nothing, extra=nothing) + status.signal_total = sum(status.signals) + return nothing +end + +PlantSimEngine.@process "model_object_signal_caller" verbose = false + +struct ModelObjectSignalCallerModel <: AbstractModel_Object_Signal_CallerModel end + +PlantSimEngine.inputs_(::ModelObjectSignalCallerModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectSignalCallerModel) = (called_signal=0.0,) +PlantSimEngine.dep(::ModelObjectSignalCallerModel) = ( + signal=Call(process=:model_object_signal_source), +) + +PlantSimEngine.@process "model_object_manual_pair_caller" verbose = false +struct ModelObjectManualPairCallerModel <: + AbstractModel_Object_Manual_Pair_CallerModel end +PlantSimEngine.inputs_(::ModelObjectManualPairCallerModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectManualPairCallerModel) = NamedTuple() +function PlantSimEngine.run!( + ::ModelObjectManualPairCallerModel, + models, + status, + meteo, + constants=nothing, + extra=nothing, +) + return nothing +end + +function PlantSimEngine.run!(::ModelObjectSignalCallerModel, models, status, meteo, constants=nothing, extra=nothing) + target = only(run_call!(extra, :signal; publish=true)) + status.called_signal = target.status.signal + return nothing +end + +PlantSimEngine.@process "model_object_meteo_call_source" verbose = false + +struct ModelObjectMeteoCallSourceModel <: AbstractModel_Object_Meteo_Call_SourceModel end + +PlantSimEngine.inputs_(::ModelObjectMeteoCallSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectMeteoCallSourceModel) = (temperature_seen=0.0,) +PlantSimEngine.meteo_inputs_(::ModelObjectMeteoCallSourceModel) = (T=0.0,) +PlantSimEngine.meteo_outputs_(::ModelObjectMeteoCallSourceModel) = (T=0.0,) + +function PlantSimEngine.run!(::ModelObjectMeteoCallSourceModel, models, status, meteo, constants=nothing, extra=nothing) + status.temperature_seen = meteo.T + status.T = meteo.T + 1.0 + return nothing +end + +PlantSimEngine.@process "model_object_meteo_call_controller" verbose = false + +struct ModelObjectMeteoCallControllerModel{T} <: AbstractModel_Object_Meteo_Call_ControllerModel + local_temperature::T + publish::Bool +end + +PlantSimEngine.inputs_(::ModelObjectMeteoCallControllerModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectMeteoCallControllerModel) = (called_temperature=0.0,) + +function PlantSimEngine.run!(m::ModelObjectMeteoCallControllerModel, models, status, meteo, constants=nothing, extra=nothing) + target = only( + run_call!( + extra, + :source; + meteo=(T=m.local_temperature,), + publish=m.publish, + ), + ) + status.called_temperature = target.status.temperature_seen + return nothing +end + +PlantSimEngine.@process "model_object_iterative_meteo_call_controller" verbose = false + +struct ModelObjectIterativeMeteoCallControllerModel{T} <: + AbstractModel_Object_Iterative_Meteo_Call_ControllerModel + trial_temperatures::NTuple{2,T} + accepted_temperature::T +end + +PlantSimEngine.inputs_(::ModelObjectIterativeMeteoCallControllerModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectIterativeMeteoCallControllerModel) = + (called_temperature=0.0,) + +function PlantSimEngine.run!( + m::ModelObjectIterativeMeteoCallControllerModel, + models, + status, + meteo, + constants=nothing, + extra=nothing, +) + target = only(call_targets(extra, :source)) + for temperature in m.trial_temperatures + run_call!(target; meteo=(T=temperature,)) + end + run_call!(target; meteo=(T=m.accepted_temperature,), publish=true) + status.called_temperature = target.status.temperature_seen + return nothing +end + +PlantSimEngine.@process "model_object_signal_consumer" verbose = false + +struct ModelObjectSignalConsumerModel <: AbstractModel_Object_Signal_ConsumerModel end + +PlantSimEngine.inputs_(::ModelObjectSignalConsumerModel) = (signal=0.0,) +PlantSimEngine.outputs_(::ModelObjectSignalConsumerModel) = (observed_signal=0.0,) + +function PlantSimEngine.run!(::ModelObjectSignalConsumerModel, models, status, meteo, constants=nothing, extra=nothing) + status.observed_signal = status.signal + return nothing +end + +PlantSimEngine.@process "model_object_renamed_signal_consumer" verbose = false + +struct ModelObjectRenamedSignalConsumerModel <: + AbstractModel_Object_Renamed_Signal_ConsumerModel end + +PlantSimEngine.inputs_(::ModelObjectRenamedSignalConsumerModel) = + (renamed_signal=0.0,) +PlantSimEngine.outputs_(::ModelObjectRenamedSignalConsumerModel) = + (observed_renamed_signal=0.0,) + +function PlantSimEngine.run!( + ::ModelObjectRenamedSignalConsumerModel, + models, + status, + meteo, + constants=nothing, + extra=nothing, +) + status.observed_renamed_signal = status.renamed_signal + return nothing +end + +PlantSimEngine.@process "model_object_optional_input_consumer" verbose = false + +struct ModelObjectOptionalInputConsumerModel <: + AbstractModel_Object_Optional_Input_ConsumerModel end + +PlantSimEngine.inputs_(::ModelObjectOptionalInputConsumerModel) = (optional_signal=7.0,) +PlantSimEngine.outputs_(::ModelObjectOptionalInputConsumerModel) = + (observed_optional_signal=0.0,) + +function PlantSimEngine.run!( + ::ModelObjectOptionalInputConsumerModel, + models, + status, + meteo, + constants=nothing, + extra=nothing, +) + status.observed_optional_signal = status.optional_signal + return nothing +end + +PlantSimEngine.@process "model_object_optional_call_consumer" verbose = false + +struct ModelObjectOptionalCallConsumerModel <: + AbstractModel_Object_Optional_Call_ConsumerModel end + +PlantSimEngine.inputs_(::ModelObjectOptionalCallConsumerModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectOptionalCallConsumerModel) = + (optional_call_count=0,) + +function PlantSimEngine.run!( + ::ModelObjectOptionalCallConsumerModel, + models, + status, + meteo, + constants=nothing, + extra=nothing, +) + status.optional_call_count = + length(run_call!(extra, :optional_source; publish=true)) + return nothing +end + +PlantSimEngine.@process "model_object_cycle_a" verbose = false + +struct ModelObjectCycleAModel <: AbstractModel_Object_Cycle_AModel end + +PlantSimEngine.inputs_(::ModelObjectCycleAModel) = (cycle_b=0.0,) +PlantSimEngine.outputs_(::ModelObjectCycleAModel) = (cycle_a=0.0,) + +function PlantSimEngine.run!(::ModelObjectCycleAModel, models, status, meteo, constants=nothing, extra=nothing) + status.cycle_a = status.cycle_b + 1.0 + return nothing +end + +PlantSimEngine.@process "model_object_cycle_b" verbose = false + +struct ModelObjectCycleBModel <: AbstractModel_Object_Cycle_BModel end + +PlantSimEngine.inputs_(::ModelObjectCycleBModel) = (cycle_a=0.0,) +PlantSimEngine.outputs_(::ModelObjectCycleBModel) = (cycle_b=0.0,) + +function PlantSimEngine.run!(::ModelObjectCycleBModel, models, status, meteo, constants=nothing, extra=nothing) + status.cycle_b = 2.0 * status.cycle_a + return nothing +end + +PlantSimEngine.@process "model_object_temporal_sum" verbose = false + +struct ModelObjectTemporalSumModel <: AbstractModel_Object_Temporal_SumModel end + +PlantSimEngine.inputs_(::ModelObjectTemporalSumModel) = (signal_sum=0.0,) +PlantSimEngine.outputs_(::ModelObjectTemporalSumModel) = (temporal_total=0.0,) + +function PlantSimEngine.run!(::ModelObjectTemporalSumModel, models, status, meteo, constants=nothing, extra=nothing) + status.temporal_total = status.signal_sum + return nothing +end + +PlantSimEngine.@process "model_object_biomass_source" verbose = false + +struct ModelObjectBiomassSourceModel <: AbstractModel_Object_Biomass_SourceModel end + +PlantSimEngine.inputs_(::ModelObjectBiomassSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectBiomassSourceModel) = (biomass=0.0,) + +function PlantSimEngine.run!(::ModelObjectBiomassSourceModel, models, status, meteo, constants=nothing, extra=nothing) + status.biomass = 10.0 + return nothing +end + +PlantSimEngine.@process "model_object_biomass_pruner" verbose = false + +struct ModelObjectBiomassPrunerModel <: AbstractModel_Object_Biomass_PrunerModel end + +PlantSimEngine.inputs_(::ModelObjectBiomassPrunerModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectBiomassPrunerModel) = (biomass=0.0,) + +function PlantSimEngine.run!(::ModelObjectBiomassPrunerModel, models, status, meteo, constants=nothing, extra=nothing) + status.biomass = 0.0 + return nothing +end + +PlantSimEngine.@process "model_object_growth" verbose = false + +struct ModelObjectGrowthModel <: AbstractModel_Object_GrowthModel end + +PlantSimEngine.inputs_(::ModelObjectGrowthModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectGrowthModel) = (created_count=0,) + +function PlantSimEngine.run!(::ModelObjectGrowthModel, models, status, meteo, constants=nothing, extra=nothing) + model = runtime_model(extra) + if isapprox(extra.time, 1.0) && !(ObjectId(:grown_leaf) in object_ids(model; scale=:Leaf)) + register_object!( + model, + Object(:grown_leaf; scale=:Leaf, parent=:plant_1, status=Status(signal=0.0)), + ) + status.created_count += 1 + end + return nothing +end + +PlantSimEngine.@process "model_object_pruning" verbose = false + +struct ModelObjectPruningModel <: AbstractModel_Object_PruningModel end + +PlantSimEngine.inputs_(::ModelObjectPruningModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectPruningModel) = (removed_count=0,) + +function PlantSimEngine.run!(::ModelObjectPruningModel, models, status, meteo, constants=nothing, extra=nothing) + model = runtime_model(extra) + if isapprox(extra.time, 2.0) && ObjectId(:leaf_2) in object_ids(model; scale=:Leaf) + remove_object!(model, :leaf_2) + status.removed_count += 1 + end + return nothing +end + +PlantSimEngine.@process "model_object_geometry_mover" verbose = false + +struct ModelObjectGeometryMoverModel <: AbstractModel_Object_Geometry_MoverModel end + +PlantSimEngine.inputs_(::ModelObjectGeometryMoverModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectGeometryMoverModel) = (move_count=0,) + +function PlantSimEngine.run!(::ModelObjectGeometryMoverModel, models, status, meteo, constants=nothing, extra=nothing) + if isapprox(extra.time, 1.0) + update_geometry!(runtime_model(extra), :leaf_1, (cell=:cell_b,)) + status.move_count += 1 + end + return nothing +end + +mutable struct ModelObjectGridBackend <: PlantSimEngine.AbstractEnvironmentBackend + binds::Vector{Any} + index_updates::Vector{Any} +end + +ModelObjectGridBackend(binds::Vector{Any}=Any[]) = ModelObjectGridBackend(binds, Any[]) + +struct ModelObjectTaggedValue + value::Int +end + +struct ModelObjectDualLike{T} + value::T + derivative::T +end + +Base.zero(::Type{ModelObjectDualLike{T}}) where {T} = + ModelObjectDualLike(zero(T), zero(T)) +Base.:+(a::ModelObjectDualLike, b::ModelObjectDualLike) = + ModelObjectDualLike(a.value + b.value, a.derivative + b.derivative) +Base.:(==)(a::ModelObjectDualLike, b::ModelObjectDualLike) = + a.value == b.value && a.derivative == b.derivative + +PlantSimEngine.@process "model_object_dual_like_sum" verbose = false + +struct ModelObjectDualLikeSumModel <: AbstractModel_Object_Dual_Like_SumModel end + +PlantSimEngine.inputs_(::ModelObjectDualLikeSumModel) = + (values=ModelObjectDualLike{BigFloat}[],) +PlantSimEngine.outputs_(::ModelObjectDualLikeSumModel) = + (total=zero(ModelObjectDualLike{BigFloat}),) + +function PlantSimEngine.run!( + ::ModelObjectDualLikeSumModel, + models, + status, + meteo, + constants=nothing, + extra=nothing, +) + status.total = sum(status.values) + return nothing +end + +PlantSimEngine.base_step_seconds(::ModelObjectGridBackend) = 3600.0 +PlantSimEngine.get_nsteps(::ModelObjectGridBackend) = 1 +PlantSimEngine.environment_variables(::ModelObjectGridBackend) = Set([:T, :CO2]) + +function PlantSimEngine.bind_environment( + backend::ModelObjectGridBackend, + object::Object, + support, + config, +) + object_geometry = geometry(object) + cell = isnothing(object_geometry) ? :global : object_geometry.cell + push!( + backend.binds, + ( + object=object.id.value, + application=support.application, + cell=cell, + config=config, + ), + ) + return cell +end + +function PlantSimEngine.update_index!(backend::ModelObjectGridBackend, entities) + push!( + backend.index_updates, + [ + ( + id=entity.id, + scale=entity.scale, + kind=entity.kind, + geometry=entity.geometry, + position=entity.position, + bounds=entity.bounds, + ) + for entity in entities + ], + ) + return nothing +end + +mutable struct ModelObjectMutableEnvironmentBackend <: PlantSimEngine.AbstractEnvironmentBackend + values::Dict{Symbol,Float64} + cells_by_status::Dict{UInt,Symbol} + writes::Vector{Any} +end + +ModelObjectMutableEnvironmentBackend(values::Pair...) = + ModelObjectMutableEnvironmentBackend(Dict{Symbol,Float64}(values), Dict{UInt,Symbol}(), Any[]) + +PlantSimEngine.base_step_seconds(::ModelObjectMutableEnvironmentBackend) = 3600.0 +PlantSimEngine.get_nsteps(::ModelObjectMutableEnvironmentBackend) = 1 +PlantSimEngine.environment_variables(::ModelObjectMutableEnvironmentBackend) = Set([:T, :CO2]) + +function PlantSimEngine.bind_environment( + backend::ModelObjectMutableEnvironmentBackend, + object::Object, + support, + config, +) + cell = object.geometry.cell + backend.cells_by_status[objectid(object.status)] = cell + return cell +end + +function PlantSimEngine.sample( + backend::ModelObjectMutableEnvironmentBackend, + variable::Symbol, + support::EnvironmentSupport, + time, +) + variable == :CO2 && return 410.0 + variable == :T || error("Unexpected variable `$(variable)`.") + cell = backend.cells_by_status[objectid(support.status)] + return backend.values[cell] +end + +function PlantSimEngine.scatter!( + backend::ModelObjectMutableEnvironmentBackend, + variable::Symbol, + support::EnvironmentSupport, + value, + time, +) + variable == :T || error("Unexpected variable `$(variable)`.") + cell = backend.cells_by_status[objectid(support.status)] + backend.values[cell] = value + push!( + backend.writes, + ( + application=support.application, + process=support.process, + cell=cell, + variable=variable, + value=value, + time=time, + ), + ) + return nothing +end + +@testset "Unified model/object API" begin + mtg_root = Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) + mtg_plant = Node(mtg_root, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) + mtg_leaf = Node(mtg_plant, MultiScaleTreeGraph.NodeMTG("+", :Leaf, 1, 2)) + mtg_leaf_status = Status(signal=2.0) + mtg_leaf[:plantsimengine_status] = mtg_leaf_status + mtg_object_id = node -> Symbol(lowercase(string(symbol(node))), "_", node_id(node)) + adapted_objects = objects_from_mtg( + mtg_root; + id=mtg_object_id, + kind=node -> symbol(node) == :Scene ? :scene : :plant, + species=node -> symbol(node) == :Scene ? nothing : :oil_palm, + geometry=node -> symbol(node) == :Leaf ? (x=1.0, y=2.0) : nothing, + ) + @test [object.id for object in adapted_objects] == + ObjectId.([:scene_1, :plant_2, :leaf_3]) + @test only(object for object in adapted_objects if object.scale == :Leaf).status === + mtg_leaf_status + mtg_scene = CompositeModel( + mtg_root; + id=mtg_object_id, + kind=node -> symbol(node) == :Scene ? :scene : :plant, + species=node -> symbol(node) == :Scene ? nothing : :oil_palm, + geometry=node -> symbol(node) == :Leaf ? (x=1.0, y=2.0) : nothing, + applications=( + ModelSpec(ModelObjectParameterizedSignalModel(1.0); name=:mtg_signal) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + @test only(model_objects(mtg_scene; scale=:Leaf)).parent == ObjectId(:plant_2) + @test only(model_objects(mtg_scene; scale=:Leaf)).status === mtg_leaf_status + @test position(only(model_objects(mtg_scene; scale=:Leaf))) == (x=1.0, y=2.0) + run!(mtg_scene) + @test only(model_objects(mtg_scene; scale=:Leaf)).status.signal == 3.0 + + new_leaf_status = add_organ!( + mtg_plant, + mtg_scene, + :+, + :Leaf, + 2; + index=2, + id=4, + attributes=(signal=4.0, color=:green), + initial_status=(signal=5.0, age=1), + kind=:plant, + ) + @test new_leaf_status.node[:plantsimengine_status] === new_leaf_status + @test new_leaf_status.signal == 5.0 + @test new_leaf_status.color == :green + @test new_leaf_status.age == 1 + new_leaf_object = only( + object for object in model_objects(mtg_scene; scale=:Leaf) + if object.id == ObjectId(:leaf_4) + ) + @test new_leaf_object.status === new_leaf_status + @test new_leaf_object.parent == ObjectId(:plant_2) + @test Advanced.bindings_dirty(mtg_scene) + + child_count = length(MultiScaleTreeGraph.children(mtg_plant)) + @test_throws ErrorException add_organ!( + mtg_plant, + mtg_scene, + :+, + :Leaf, + 2; + index=3, + id=4, + ) + @test length(MultiScaleTreeGraph.children(mtg_plant)) == child_count + + auto_id_leaf_status = add_organ!( + mtg_plant, + mtg_scene, + :+, + :Leaf, + 2; + index=3, + ) + @test node_id(auto_id_leaf_status.node) == 5 + @test auto_id_leaf_status.node[:plantsimengine_status] === auto_id_leaf_status + + model = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, species=:oil_palm, parent=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, species=:oil_palm, parent=:plant_1, geometry=(x=1.0, y=0.0)), + Object(:leaf_2; scale=:Leaf, kind=:plant, species=:oil_palm, parent=:plant_1), + ) + + @test object_ids(model; scale=:Leaf) == [ObjectId(:leaf_1), ObjectId(:leaf_2)] + @test object_ids(model; kind=:plant, species=:oil_palm) == [ObjectId(:leaf_1), ObjectId(:leaf_2), ObjectId(:plant_1)] + @test only(model_objects(model; scale=:Scene)).id == ObjectId(:scene) + + leaf_2 = move_object!(model, :leaf_2, (x=2.0, y=0.0)) + @test leaf_2.geometry == (x=2.0, y=0.0) + @test geometry(leaf_2) == (x=2.0, y=0.0) + @test position(leaf_2) == (x=2.0, y=0.0) + @test isnothing(bounds(leaf_2)) + bounded_leaf = Object(:bounded_leaf; scale=:Leaf, geometry=(position=(x=1.0, y=2.0, z=3.0), bounds=(radius=0.5,))) + @test position(bounded_leaf) == (x=1.0, y=2.0, z=3.0) + @test bounds(bounded_leaf) == (radius=0.5,) + + new_axis = register_object!(model, Object(:axis_1; scale=:Axis, kind=:plant, species=:oil_palm); parent=:plant_1) + @test new_axis.parent == ObjectId(:plant_1) + @test ObjectId(:axis_1) in only(model_objects(model; scale=:Plant)).children + + reparent_object!(model, :leaf_2, :axis_1) + @test only(model_objects(model; name=nothing, scale=:Axis)).children == [ObjectId(:leaf_2)] + @test ObjectId(:leaf_2) ∉ only(model_objects(model; scale=:Plant)).children + + removed_axis = remove_object!(model, :axis_1) + @test removed_axis.id == ObjectId(:axis_1) + @test object_ids(model; scale=:Axis) == ObjectId[] + @test object_ids(model; name=:leaf_2) == ObjectId[] + + object_rows = explain_objects(model) + @test length(object_rows) == 3 + @test any(row -> row.id == :leaf_1 && row.has_geometry, object_rows) + @test any(row -> row.id == :plant_1 && row.children == [ObjectId(:leaf_1).value], object_rows) + + selector_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, species=:oil_palm, name=:palm_1, parent=:scene), + Object(:axis_1; scale=:Axis, kind=:plant, species=:oil_palm, parent=:plant_1), + Object(:leaf_1; scale=:Leaf, kind=:plant, species=:oil_palm, parent=:plant_1, status=Status(leaf_area=1.0)), + Object(:leaf_2; scale=:Leaf, kind=:plant, species=:oil_palm, parent=:axis_1, status=Status(leaf_area=2.0)), + Object(:plant_2; scale=:Plant, kind=:plant, species=:oil_palm, name=:palm_2, parent=:scene), + Object(:leaf_3; scale=:Leaf, kind=:plant, species=:oil_palm, parent=:plant_2, status=Status(leaf_area=3.0)), + Object(:soil; scale=:Soil, kind=:soil, parent=:scene), + ) + + scope_rows = explain_scopes(selector_scene) + scene_scope = only(row for row in scope_rows if row.scope_type == :scene) + @test scene_scope.selector isa SceneScope + @test scene_scope.object_ids == [:axis_1, :leaf_1, :leaf_2, :leaf_3, :plant_1, :plant_2, :scene, :soil] + plant_1_scope = only(row for row in scope_rows if row.scope_type == :object_subtree && row.root_id == :plant_1) + @test plant_1_scope.selector isa Subtree + @test plant_1_scope.object_ids == [:axis_1, :leaf_1, :leaf_2, :plant_1] + palm_2_scope = only(row for row in scope_rows if row.scope_type == :named_scope && row.name == :palm_2) + @test palm_2_scope.selector isa Scope + @test palm_2_scope.root_id == :plant_2 + @test palm_2_scope.object_ids == [:leaf_3, :plant_2] + leaf_label_scope = only(row for row in scope_rows if row.scope_type == :scale && row.scale == :Leaf) + @test leaf_label_scope.selector == (:scale => :Leaf) + @test leaf_label_scope.object_ids == [:leaf_1, :leaf_2, :leaf_3] + oil_palm_scope = only(row for row in scope_rows if row.scope_type == :species && row.species == :oil_palm) + @test oil_palm_scope.object_ids == [:axis_1, :leaf_1, :leaf_2, :leaf_3, :plant_1, :plant_2] + + @test resolve_object_ids(selector_scene, Many(scale=:Leaf)) == + [ObjectId(:leaf_1), ObjectId(:leaf_2), ObjectId(:leaf_3)] + @test only(resolve_objects(selector_scene, One(scale=:Scene))).id == ObjectId(:scene) + @test resolve_object_ids(selector_scene, Many(Kind(:plant), Scale(:Leaf))) == + [ObjectId(:leaf_1), ObjectId(:leaf_2), ObjectId(:leaf_3)] + @test resolve_object_ids(selector_scene, Many(scale=:Leaf, within=Subtree()); context=:plant_1) == + [ObjectId(:leaf_1), ObjectId(:leaf_2)] + @test resolve_object_ids(selector_scene, Many(scale=:Leaf, within=Subtree()); context=:leaf_2) == + [ObjectId(:leaf_2)] + @test resolve_object_ids(selector_scene, Many(scale=:Leaf, within=SelfPlant()); context=:leaf_2) == + [ObjectId(:leaf_1), ObjectId(:leaf_2)] + @test resolve_object_ids(selector_scene, Many(scale=:Leaf, within=Ancestor(scale=:Axis)); context=:leaf_2) == + [ObjectId(:leaf_2)] + @test resolve_object_ids(selector_scene, Many(scale=:Leaf, within=Scope(:palm_2))) == + [ObjectId(:leaf_3)] + @test resolve_object_ids(selector_scene, One(Relation(:parent)); context=:leaf_2) == + [ObjectId(:axis_1)] + @test resolve_object_ids(selector_scene, Many(Relation(:children)); context=:plant_1) == + [ObjectId(:axis_1), ObjectId(:leaf_1)] + @test resolve_object_ids(selector_scene, Many(Relation(:ancestors)); context=:leaf_2) == + [ObjectId(:axis_1), ObjectId(:plant_1), ObjectId(:scene)] + @test resolve_object_ids(selector_scene, Many(Relation(:descendants), Scale(:Leaf)); context=:plant_1) == + [ObjectId(:leaf_1), ObjectId(:leaf_2)] + @test resolve_object_ids(selector_scene, Many(Relation(:siblings)); context=:axis_1) == + [ObjectId(:leaf_1)] + @test resolve_object_ids( + selector_scene, + Many(Relation(:ancestors), Scale(:Plant), within=SceneScope()); + context=:leaf_2, + ) == [ObjectId(:plant_1)] + @test_throws "require a current object context" resolve_object_ids( + selector_scene, + Many(Relation(:children)), + ) + @test_throws "Unsupported object relation" Relation(:cousins) + @test resolve_object_ids(selector_scene, OptionalOne(scale=:Flower)) == ObjectId[] + @test_throws ErrorException resolve_object_ids(selector_scene, One(scale=:Flower)) + @test_throws ErrorException resolve_object_ids(selector_scene, One(scale=:Leaf)) + typo_selector_error = try + resolve_object_ids(selector_scene, One(scale=:Leef)) + nothing + catch error + sprint(showerror, error) + end + @test contains(typo_selector_error, "requested=(scale=Leef") + @test contains(typo_selector_error, "available=(scales = [:Axis, :Leaf, :Plant, :Scene, :Soil]") + @test contains(typo_selector_error, "suggestions=(scale = [:Leaf]") + ambiguous_selector_error = try + resolve_object_ids(selector_scene, One(scale=:Leaf)) + nothing + catch error + sprint(showerror, error) + end + @test contains(ambiguous_selector_error, "matched_ids=[:leaf_1, :leaf_2, :leaf_3]") + scope_selector_error = try + resolve_object_ids(selector_scene, Many(scale=:Leaf, within=Scope(:palm_3))) + nothing + catch error + sprint(showerror, error) + end + @test contains(scope_selector_error, "available=[:axis_1") + @test contains(scope_selector_error, "suggestions=[:palm_1, :palm_2]") + @test_throws ErrorException resolve_object_ids(selector_scene, Many(scale=:Leaf, within=Subtree())) + @test resolve_object_ids(selector_scene, Many(scale=:Leaf); context=:plant_1) == + [ObjectId(:leaf_1), ObjectId(:leaf_2), ObjectId(:leaf_3)] + + relation_input_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :plant_1; + scale=:Plant, + kind=:plant, + parent=:scene, + status=Status(signal=0.0), + ), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:plant_1, + status=Status(signal=0.0, observed_signal=0.0), + ); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:plant_signal) |> + AppliesTo(One(scale=:Plant)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:leaf_consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs( + :signal => One( + Relation(:parent), + process=:model_object_signal_source, + var=:signal, + ), + ), + ), + ) + relation_input_compiled = Advanced.refresh_bindings!(relation_input_scene) + relation_binding = only( + row for row in explain_bindings(relation_input_compiled) + if row.application_id == :leaf_consumer + ) + @test relation_binding.source_ids == [:plant_1] + @test object_address(relation_binding.selector).relation == :parent + @test relation_input_compiled.application_order == [:plant_signal, :leaf_consumer] + run!(relation_input_scene) + @test only(model_objects(relation_input_scene; scale=:Leaf)).status.observed_signal == 1.0 + + shared_signal_model = ModelObjectParameterizedSignalModel(1.0) + shared_template_parameters = Dict(:signal_increment => 1.0) + plant_template = CompositeModelTemplate( + ( + ModelSpec(shared_signal_model; name=:signal_source) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ModelObjectPlantSignalSumModel(); name=:plant_total) |> + AppliesTo(One(scale=:Plant)) |> + Inputs( + :signals => Many( + scale=:Leaf, + within=Subtree(), + process=:model_object_signal_source, + var=:signal, + ), + ), + ); + kind=:plant, + species=:oil_palm, + parameters=shared_template_parameters, + ) + palm_1_leaf_override = ModelObjectParameterizedSignalModel(3.0) + palm_1 = ObjectInstance( + :palm_1, + plant_template; + root=Object(:templated_plant_1; scale=:Plant, parent=:scene, status=Status(signals=[0.0], signal_total=0.0)), + objects=( + Object(:templated_leaf_1; scale=:Leaf, parent=:templated_plant_1, status=Status(signal=0.0)), + Object(:templated_leaf_1_exception; scale=:Leaf, parent=:templated_plant_1, status=Status(signal=0.0)), + ), + object_overrides=( + Override( + object=:templated_leaf_1_exception, + application=:signal_source, + model=palm_1_leaf_override, + ), + ), + ) + palm_2_override = ModelObjectParameterizedSignalModel(2.0) + palm_2 = ObjectInstance( + :palm_2, + plant_template; + root=Object(:templated_plant_2; scale=:Plant, parent=:scene, status=Status(signals=[0.0], signal_total=0.0)), + objects=(Object(:templated_leaf_2; scale=:Leaf, parent=:templated_plant_2, status=Status(signal=0.0)),), + overrides=(model_object_signal_source=palm_2_override,), + ) + palm_3 = ObjectInstance( + :palm_3, + plant_template; + root=Object(:templated_plant_3; scale=:Plant, parent=:scene, status=Status(signals=[0.0], signal_total=0.0)), + objects=(Object(:templated_leaf_3; scale=:Leaf, parent=:templated_plant_3, status=Status(signal=0.0)),), + ) + templated_plant_4 = Object( + :templated_plant_4; + scale=:Plant, + parent=:scene, + status=Status(signals=[0.0], signal_total=0.0), + ) + templated_leaf_4 = Object( + :templated_leaf_4; + scale=:Leaf, + parent=:templated_plant_4, + status=Status(signal=0.0), + ) + palm_4 = ObjectInstance(:palm_4, plant_template; root=:templated_plant_4) + template_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + templated_plant_4, + templated_leaf_4, + palm_1, + palm_2, + palm_3; + instances=(palm_4,), + ) + @test length(template_scene.applications) == 8 + @test plant_template.parameters === shared_template_parameters + @test only(model_objects(template_scene; name=:palm_1)).id == ObjectId(:templated_plant_1) + @test object_ids(template_scene; species=:oil_palm) == [ + ObjectId(:templated_leaf_1), + ObjectId(:templated_leaf_1_exception), + ObjectId(:templated_leaf_2), + ObjectId(:templated_leaf_3), + ObjectId(:templated_leaf_4), + ObjectId(:templated_plant_1), + ObjectId(:templated_plant_2), + ObjectId(:templated_plant_3), + ObjectId(:templated_plant_4), + ] + template_compiled = Advanced.compile_composite_model(template_scene) + template_application_rows = explain_applications(template_compiled) + @test only(row for row in template_application_rows if row.application_id == :palm_1__signal_source).target_ids == + [:templated_leaf_1, :templated_leaf_1_exception] + @test only(row for row in template_application_rows if row.application_id == :palm_2__signal_source).target_ids == + [:templated_leaf_2] + @test only(row for row in template_application_rows if row.application_id == :palm_3__plant_total).target_ids == + [:templated_plant_3] + palm_1_signal_row = only( + row for row in template_application_rows + if row.application_id == :palm_1__signal_source + ) + @test palm_1_signal_row.model_type == typeof(shared_signal_model) + @test palm_1_signal_row.model_storage == :per_object_override + @test palm_1_signal_row.model_dispatch == :concrete_per_object + @test palm_1_signal_row.object_overrides == [ + ( + object_id=:templated_leaf_1_exception, + model_type=typeof(palm_1_leaf_override), + ), + ] + @test (@inferred PlantSimEngine._application_model( + template_compiled.applications_by_id[:palm_1__signal_source], + ObjectId(:templated_leaf_1), + )) === shared_signal_model + @test (@inferred PlantSimEngine._application_model( + template_compiled.applications_by_id[:palm_1__signal_source], + ObjectId(:templated_leaf_1_exception), + )) === palm_1_leaf_override + @test PlantSimEngine.model_(template_compiled.applications_by_id[:palm_3__signal_source].spec) === shared_signal_model + @test PlantSimEngine.model_(template_compiled.applications_by_id[:palm_4__signal_source].spec) === shared_signal_model + @test PlantSimEngine.model_(template_compiled.applications_by_id[:palm_2__signal_source].spec) === palm_2_override + template_instance_rows = explain_instances(template_scene) + palm_1_instance_row = only(row for row in template_instance_rows if row.name == :palm_1) + @test palm_1_instance_row.root_id == :templated_plant_1 + @test palm_1_instance_row.object_ids == + [:templated_leaf_1, :templated_leaf_1_exception, :templated_plant_1] + @test palm_1_instance_row.application_ids == + [:palm_1__plant_total, :palm_1__signal_source] + @test palm_1_instance_row.object_overrides == [ + ( + object_id=:templated_leaf_1_exception, + process=nothing, + application=:signal_source, + model_type=typeof(palm_1_leaf_override), + ), + ] + @test palm_1_instance_row.parameters_shared_by_reference + @test only(row for row in explain_objects(template_scene) if row.id == :templated_leaf_1).instance == + :palm_1 + run!(template_scene; steps=1) + @test only(model_objects(template_scene; name=:palm_1)).status.signal_total == 4.0 + @test only(model_objects(template_scene; name=:palm_2)).status.signal_total == 2.0 + @test only(model_objects(template_scene; name=:palm_3)).status.signal_total == 1.0 + @test only(model_objects(template_scene; name=:palm_4)).status.signal_total == 1.0 + registered_template_leaf = register_object!( + template_scene, + Object(:templated_leaf_new; scale=:Leaf, status=Status(signal=0.0)); + parent=:templated_plant_2, + ) + @test registered_template_leaf.kind == :plant + @test registered_template_leaf.species == :oil_palm + @test :templated_leaf_new in only( + row.object_ids for row in explain_instances(template_scene) + if row.name == :palm_2 + ) + refreshed_template = Advanced.refresh_bindings!(template_scene) + @test ObjectId(:templated_leaf_new) in + refreshed_template.applications_by_id[:palm_2__signal_source].target_ids + remove_object!(template_scene, :templated_leaf_new) + @test_throws ErrorException CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + ObjectInstance( + :invalid_palm, + plant_template; + root=Object(:invalid_plant; scale=:Plant, parent=:scene), + overrides=(missing_process=shared_signal_model,), + ), + ) + @test_throws ErrorException CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + ObjectInstance( + :invalid_palm, + plant_template; + root=Object(:invalid_plant; scale=:Plant, parent=:scene), + overrides=(signal_source=Process1Model(1.0),), + ), + ) + @test_throws ErrorException CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + ObjectInstance( + :invalid_palm, + plant_template; + root=Object(:invalid_plant; scale=:Plant, parent=:scene), + objects=(Object(:invalid_leaf; scale=:Leaf, parent=:invalid_plant),), + object_overrides=( + Override( + object=:outside_instance, + application=:signal_source, + model=shared_signal_model, + ), + ), + ), + ) + @test_throws ErrorException CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + ObjectInstance( + :invalid_palm, + plant_template; + root=Object(:invalid_plant; scale=:Plant, parent=:scene), + objects=(Object(:invalid_leaf; scale=:Leaf, parent=:invalid_plant),), + object_overrides=( + Override( + object=:invalid_leaf, + application=:signal_source, + model=shared_signal_model, + ), + Override( + object=:invalid_leaf, + process=:model_object_signal_source, + model=shared_signal_model, + ), + ), + ), + ) + unmatched_override_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + ObjectInstance( + :invalid_palm, + plant_template; + root=Object(:invalid_plant; scale=:Plant, parent=:scene), + objects=(Object(:invalid_leaf; scale=:Leaf, parent=:invalid_plant, status=Status(signal=0.0)),), + object_overrides=( + Override( + object=:invalid_plant, + application=:signal_source, + model=shared_signal_model, + ), + ), + ), + ) + @test_throws ErrorException Advanced.compile_composite_model(unmatched_override_scene) + + call_template = CompositeModelTemplate( + ( + ModelSpec(shared_signal_model; name=:signal_source) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ModelObjectSignalCallerModel(); name=:signal_caller) |> + AppliesTo(Many(scale=:Leaf)), + ); + kind=:plant, + species=:oil_palm, + ) + call_override_model = ModelObjectParameterizedSignalModel(4.0) + call_override_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + ObjectInstance( + :call_palm, + call_template; + root=Object(:call_plant; scale=:Plant, parent=:scene), + objects=( + Object(:call_leaf_1; scale=:Leaf, parent=:call_plant, status=Status(signal=0.0, called_signal=0.0)), + Object(:call_leaf_2; scale=:Leaf, parent=:call_plant, status=Status(signal=0.0, called_signal=0.0)), + ), + object_overrides=( + Override( + object=:call_leaf_2, + application=:signal_source, + model=call_override_model, + ), + ), + ), + ) + run!(call_override_scene) + call_leaf_1 = only(object for object in model_objects(call_override_scene; scale=:Leaf) if object.id == ObjectId(:call_leaf_1)) + call_leaf_2 = only(object for object in model_objects(call_override_scene; scale=:Leaf) if object.id == ObjectId(:call_leaf_2)) + @test call_leaf_1.status.called_signal == 1.0 + @test call_leaf_2.status.called_signal == 4.0 + + leaf_selector = Many( + kind="plant", + scale=:Leaf, + within=Subtree(), + process="leaf_state", + var="leaf_area", + policy=Integrate(), + window=Day(1), + ) + + @test leaf_selector.criteria.kind == :plant + @test leaf_selector.criteria.scale == :Leaf + @test leaf_selector.criteria.within isa Subtree + @test leaf_selector.criteria.process == :leaf_state + @test leaf_selector.criteria.var == :leaf_area + @test leaf_selector.criteria.policy isa Integrate + @test leaf_selector.criteria.window == Day(1) + + address = object_address(leaf_selector) + @test address.scope isa Subtree + @test address.kind == :plant + @test address.scale == :Leaf + @test address.process == :leaf_state + @test address.var == :leaf_area + @test address.multiplicity == :many + + @test One(Kind(:plant), Scale(:Leaf)).criteria.selectors == (Kind(:plant), Scale(:Leaf)) + @test object_address(OptionalOne(scale=:Scene)).multiplicity == :optional_one + + default_input = Input(Many(scale=:Leaf, within=Subtree(), var=:leaf_carbon)) + @test default_input.selector isa Many + @test default_input.selector.criteria.within isa Subtree + + default_call = Call(process=:stomatal_conductance) + @test default_call.selector isa One + @test object_address(default_call.selector).process == :stomatal_conductance + + m = Process1Model(1.0) + spec = ModelSpec(m; name=:leaf_energy) |> + AppliesTo(Many(kind=:plant, scale=:Leaf)) |> + Inputs( + :leaf_areas => Many(kind=:plant, scale=:Leaf, within=SceneScope(), var=:leaf_area), + :leaf_carbon => Many(scale=:Leaf, within=Subtree(), var=:leaf_carbon, policy=Integrate(), window=Day(1)), + ) |> + Calls(:stomata => One(scale=:Leaf, process=:stomatal_conductance)) |> + TimeStep(Hour(1)) |> + Environment(provider=:global) + + @test PlantSimEngine.model_(spec) === m + @test application_name(spec) == :leaf_energy + @test applies_to(spec) isa Many + @test applies_to(spec).criteria.kind == :plant + @test value_inputs(spec).leaf_areas isa Many + @test PlantSimEngine.input_origins(spec).leaf_areas == :model_spec + @test PlantSimEngine.input_origins(spec).leaf_carbon == :model_spec + @test value_inputs(spec).leaf_carbon.criteria.policy isa Integrate + @test value_inputs(spec).leaf_carbon.criteria.window == Day(1) + @test model_calls(spec).stomata isa One + @test PlantSimEngine.call_origins(spec).stomata == :model_spec + @test object_address(model_calls(spec).stomata).process == :stomatal_conductance + @test isempty(dep(spec)) + @test PlantSimEngine.timestep(spec) == Hour(1) + @test environment_config(spec) isa PlantSimEngine.EnvironmentConfig + @test environment_config(spec).config.provider == :global + + leaf_assim = ModelSpec(ToyAssimModel()) |> + AppliesTo(Many(scale=:Leaf)) |> + Inputs(:soil_water_content => One(scale=:Soil, var=:soil_water_content)) + @test value_inputs(leaf_assim).soil_water_content.criteria.scale == :Soil + @test value_inputs(leaf_assim).soil_water_content.criteria.var == + :soil_water_content + + rich_selector_spec = ModelSpec(ToyAssimModel()) |> + Inputs(:soil_water_content => One(kind=:soil, scale=:Soil, var=:soil_water_content)) + @test value_inputs(rich_selector_spec).soil_water_content.criteria.kind == :soil + + default_input_spec = ModelSpec(ModelObjectDefaultInputConsumerModel()) + @test value_inputs(default_input_spec).leaf_carbon isa Many + @test PlantSimEngine.input_origins(default_input_spec).leaf_carbon == + :model_default + @test value_inputs(default_input_spec).leaf_carbon.criteria.within isa Subtree + @test !haskey(dep(default_input_spec), :leaf_carbon) + + override_input_spec = ModelSpec(ModelObjectDefaultInputConsumerModel()) |> + Inputs(:leaf_carbon => Many(scale=:Leaf, var=:carbon_override)) + @test value_inputs(override_input_spec).leaf_carbon.criteria.var == :carbon_override + @test PlantSimEngine.input_origins(override_input_spec).leaf_carbon == + :model_spec + + default_input_origin_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, parent=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:plant_1, + status=Status(leaf_carbon=2.0, carbon_override=3.0), + ), + ) + default_input_origin_compiled = Advanced.compile_composite_model( + default_input_origin_scene, + ( + default_input_spec |> + AppliesTo(One(scale=:Plant)), + ), + ) + @test only(explain_bindings(default_input_origin_compiled)).origin == + :model_default + override_input_origin_compiled = Advanced.compile_composite_model( + default_input_origin_scene, + ( + override_input_spec |> + AppliesTo(One(scale=:Plant)), + ), + ) + @test only(explain_bindings(override_input_origin_compiled)).origin == + :model_spec + + default_call_spec = ModelSpec(ModelObjectDefaultCallConsumerModel()) + @test model_calls(default_call_spec).stomata isa One + @test PlantSimEngine.call_origins(default_call_spec).stomata == + :model_default + @test model_calls(default_call_spec).stomata.criteria.scale == :Leaf + @test model_calls(default_call_spec).stomata.criteria.process == :stomatal_conductance + @test isempty(dep(default_call_spec)) + + override_call_spec = ModelSpec(ModelObjectDefaultCallConsumerModel()) |> + Calls(:stomata => One(scale=:Internode, process=:water_status)) + @test model_calls(override_call_spec).stomata.criteria.scale == :Internode + @test PlantSimEngine.call_origins(override_call_spec).stomata == + :model_spec + @test model_calls(override_call_spec).stomata.criteria.process == :water_status + @test isempty(dep(override_call_spec)) + + manual_child_scene = CompositeModel( + Object( + :manual_child_leaf; + scale=:Leaf, + status=Status(signal=0.0, observed_signal=0.0), + ); + applications=( + ModelSpec(ModelObjectManualPairCallerModel(); name=:manual_parent) |> + AppliesTo(One(scale=:Leaf)) |> + Calls( + :source => One(scale=:Leaf, application=:manual_source), + :consumer => One(scale=:Leaf, application=:manual_consumer), + ), + ModelSpec(ModelObjectSignalSetModel(1.0); name=:root_source) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectSignalSetModel(2.0); name=:manual_source) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:manual_consumer) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + manual_child_compiled = Advanced.compile_composite_model(manual_child_scene) + @test isempty( + filter( + binding -> binding.application_id == :manual_consumer, + manual_child_compiled.input_bindings, + ), + ) + + compiled_specs = ( + ModelSpec(ModelObjectStomataModel(); name=:stomata) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ModelObjectLeafEnergyModel(); name=:leaf_energy) |> + AppliesTo(Many(scale=:Leaf)) |> + Inputs(:leaf_areas => Many(scale=:Leaf, within=SelfPlant(), var=:leaf_area, policy=Integrate(), window=Day(1))), + ) + compiled = Advanced.compile_composite_model(selector_scene, compiled_specs) + application_rows = explain_applications(compiled) + @test length(application_rows) == 2 + @test only(row for row in application_rows if row.application_id == :stomata).target_ids == + [:leaf_1, :leaf_2, :leaf_3] + @test only(row for row in application_rows if row.application_id == :leaf_energy).target_ids == + [:leaf_1, :leaf_2, :leaf_3] + + binding_rows = explain_bindings(compiled) + @test length(binding_rows) == 3 + leaf_2_binding = only(row for row in binding_rows if row.consumer_id == :leaf_2) + @test leaf_2_binding.application_id == :leaf_energy + @test leaf_2_binding.origin == :model_spec + @test leaf_2_binding.input == :leaf_areas + @test leaf_2_binding.source_ids == [:leaf_1, :leaf_2] + @test leaf_2_binding.source_var == :leaf_area + @test leaf_2_binding.carrier_hint == :temporal_stream + @test leaf_2_binding.carrier_kind == :temporal_stream + @test leaf_2_binding.copy_semantics == :materialized_temporal_value + + call_rows = explain_calls(compiled) + @test length(call_rows) == 3 + leaf_2_call = only(row for row in call_rows if row.consumer_id == :leaf_2) + @test leaf_2_call.application_id == :leaf_energy + @test leaf_2_call.origin == :model_default + @test leaf_2_call.call == :stomata + @test leaf_2_call.callee_object_ids == [:leaf_2] + @test leaf_2_call.callee_application_ids == [:stomata] + @test leaf_2_call.process == :model_object_stomata + @test leaf_2_call.publication_policy == :explicit_accept + @test !leaf_2_call.default_publish + @test leaf_2_call.accepted_publish + + leaf_2_application = compiled.applications_by_id[:leaf_energy] + leaf_2_models = compiled.model_bundles_by_target[(:leaf_energy, ObjectId(:leaf_2))] + @test keys(leaf_2_models) == (:model_object_leaf_energy, :model_object_stomata) + @test leaf_2_models.model_object_leaf_energy === + PlantSimEngine._application_model(leaf_2_application, ObjectId(:leaf_2)) + @test leaf_2_models.model_object_stomata === + PlantSimEngine._application_model(compiled.applications_by_id[:stomata], ObjectId(:leaf_2)) + @test PlantSimEngine._model_models_for_application( + compiled, + leaf_2_application, + ObjectId(:leaf_2), + ) === leaf_2_models + PlantSimEngine._model_models_for_application(compiled, leaf_2_application, ObjectId(:leaf_2)) + @test @allocated( + PlantSimEngine._model_models_for_application( + compiled, + leaf_2_application, + ObjectId(:leaf_2), + ) + ) == 0 + bundle_row = only( + row for row in explain_model_bundles(compiled) + if row.application_id == :leaf_energy && row.object_id == :leaf_2 + ) + @test bundle_row.processes == [:model_object_leaf_energy, :model_object_stomata] + @test bundle_row.model_types == [ModelObjectLeafEnergyModel, ModelObjectStomataModel] + + compiled_environment = Advanced.compile_environment_bindings(selector_scene, compiled) + execution_plan = + PlantSimEngine.compile_model_execution_plan(compiled, compiled_environment) + execution_rows = explain_execution_plan(execution_plan) + @test length(execution_rows) == 1 + @test only(execution_rows).application_id == :leaf_energy + @test only(execution_rows).object_ids == [:leaf_1, :leaf_2, :leaf_3] + @test only(execution_rows).batch_size == 3 + @test only(execution_rows).inner_loop_dispatch == :concrete_homogeneous_batch + @test isconcretetype(only(execution_rows).target_type) + + batch_counter = Ref(0) + batch_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + ( + Object( + Symbol(:batch_leaf_, index); + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(), + ) + for index in 1:128 + )...; + applications=( + ModelSpec( + ModelObjectBatchCounterModel(batch_counter); + name=:batch_counter, + ) |> + AppliesTo(Many(scale=:Leaf)), + ), + ) + batch_compiled = Advanced.refresh_bindings!(batch_scene) + batch_environment = Advanced.refresh_environment_bindings!(batch_scene, batch_compiled) + batch_plan = + PlantSimEngine.compile_model_execution_plan(batch_compiled, batch_environment) + @test length(batch_plan.batches) == 1 + homogeneous_batch = only(batch_plan.batches) + @test isconcretetype(eltype(homogeneous_batch.targets)) + PlantSimEngine._run_model_execution_batch!( + homogeneous_batch, + batch_compiled, + batch_environment; + time=1, + temporal_streams=nothing, + ) + @test @allocated( + PlantSimEngine._run_model_execution_batch!( + homogeneous_batch, + batch_compiled, + batch_environment; + time=1, + temporal_streams=nothing, + ) + ) == 0 + @test batch_counter[] == 256 + + heterogeneous_template = CompositeModelTemplate( + ( + ModelSpec(ModelObjectParameterizedSignalModel(1.0); name=:signal) |> + AppliesTo(Many(scale=:Leaf)), + ); + kind=:plant, + ) + heterogeneous_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + ObjectInstance( + :heterogeneous_plant, + heterogeneous_template; + root=Object( + :heterogeneous_plant_object; + scale=:Plant, + parent=:scene, + ), + objects=( + Object( + :heterogeneous_leaf_a; + scale=:Leaf, + parent=:heterogeneous_plant_object, + status=Status(signal=0.0), + ), + Object( + :heterogeneous_leaf_b; + scale=:Leaf, + parent=:heterogeneous_plant_object, + status=Status(signal=0.0), + ), + Object( + :heterogeneous_leaf_c; + scale=:Leaf, + parent=:heterogeneous_plant_object, + status=Status(signal=0.0), + ), + ), + object_overrides=( + Override( + object=:heterogeneous_leaf_b, + application=:signal, + model=ModelObjectAlternativeSignalModel(), + ), + ), + ), + ) + heterogeneous_compiled = Advanced.refresh_bindings!(heterogeneous_scene) + heterogeneous_environment = + Advanced.refresh_environment_bindings!(heterogeneous_scene, heterogeneous_compiled) + heterogeneous_plan = PlantSimEngine.compile_model_execution_plan( + heterogeneous_compiled, + heterogeneous_environment, + ) + heterogeneous_rows = explain_execution_plan(heterogeneous_plan) + @test getproperty.(heterogeneous_rows, :object_ids) == [ + [:heterogeneous_leaf_a], + [:heterogeneous_leaf_b], + [:heterogeneous_leaf_c], + ] + @test getproperty.(heterogeneous_rows, :model_type) == [ + ModelObjectParameterizedSignalModel{Float64}, + ModelObjectAlternativeSignalModel, + ModelObjectParameterizedSignalModel{Float64}, + ] + run!(heterogeneous_scene) + heterogeneous_values = Dict( + object.id.value => object.status.signal + for object in model_objects(heterogeneous_scene; scale=:Leaf) + ) + @test heterogeneous_values == Dict( + :heterogeneous_leaf_a => 1.0, + :heterogeneous_leaf_b => 7.0, + :heterogeneous_leaf_c => 1.0, + ) + + ambiguous_call_specs = ( + ModelSpec(ModelObjectStomataModel(); name=:sunlit_stomata) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ModelObjectStomataModel(); name=:shaded_stomata) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ModelObjectLeafEnergyModel(); name=:leaf_energy) |> + AppliesTo(Many(scale=:Leaf)), + ) + @test_throws ErrorException Advanced.compile_composite_model(selector_scene, ambiguous_call_specs) + + disambiguated_call_specs = ( + ModelSpec(ModelObjectStomataModel(); name=:sunlit_stomata) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ModelObjectStomataModel(); name=:shaded_stomata) |> + AppliesTo(Many(scale=:Leaf)) |> + Updates(:gs; after=:sunlit_stomata), + ModelSpec(ModelObjectLeafEnergyModel(); name=:leaf_energy) |> + AppliesTo(Many(scale=:Leaf)) |> + Inputs(:leaf_areas => Many(scale=:Leaf, within=SelfPlant(), var=:leaf_area)) |> + Calls(:stomata => One(process=:model_object_stomata, application=:sunlit_stomata)), + ) + disambiguated = Advanced.compile_composite_model(selector_scene, disambiguated_call_specs) + disambiguated_call = only(row for row in explain_calls(disambiguated) if row.consumer_id == :leaf_2) + @test disambiguated_call.origin == :model_spec + @test disambiguated_call.callee_application_ids == [:sunlit_stomata] + @test disambiguated_call.application == :sunlit_stomata + leaf_2_call_bindings = disambiguated.call_bindings_by_target[(:leaf_energy, ObjectId(:leaf_2))] + @test length(leaf_2_call_bindings) == 1 + @test only(leaf_2_call_bindings).call == :stomata + + optional_dependency_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(optional_signal=99.0), + ); + applications=( + ModelSpec( + ModelObjectOptionalInputConsumerModel(); + name=:optional_input_consumer, + ) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :optional_signal => OptionalOne( + scale=:Leaf, + within=SceneScope(), + process=:missing_optional_source, + var=:optional_signal, + ), + ), + ModelSpec( + ModelObjectOptionalCallConsumerModel(); + name=:optional_call_consumer, + ) |> + AppliesTo(One(scale=:Scene)) |> + Calls( + :optional_source => OptionalOne( + scale=:Leaf, + within=SceneScope(), + process=:missing_optional_source, + ), + ), + ), + ) + optional_compiled = Advanced.refresh_bindings!(optional_dependency_scene) + optional_binding = only(explain_bindings(optional_compiled)) + @test optional_binding.multiplicity == :optional_one + @test isempty(optional_binding.source_ids) + @test isempty(optional_binding.source_application_ids) + @test optional_binding.carrier_hint == :optional_default + @test optional_binding.carrier_kind == :optional_default + @test optional_binding.copy_semantics == :consumer_default + optional_call = only(explain_calls(optional_compiled)) + @test optional_call.multiplicity == :optional_one + @test optional_call.callee_object_ids == [:leaf_1] + @test isempty(optional_call.callee_application_ids) + @test !optional_call.resolved + run!(optional_dependency_scene) + optional_model_status = + only(model_objects(optional_dependency_scene; scale=:Scene)).status + @test optional_model_status.optional_signal == 7.0 + @test optional_model_status.observed_optional_signal == 7.0 + @test optional_model_status.optional_call_count == 0 + + renamed_input_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene); + applications=( + ModelSpec( + ModelObjectRenamedSignalConsumerModel(); + name=:renamed_consumer, + ) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs( + :renamed_signal => One( + scale=:Leaf, + within=Subtree(), + application=:signal_source, + var=:signal, + ), + ), + ModelSpec(ModelObjectSignalSetModel(12.5); name=:signal_source) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + renamed_compiled = Advanced.refresh_bindings!(renamed_input_scene) + renamed_binding = only(explain_bindings(renamed_compiled)) + @test renamed_binding.input == :renamed_signal + @test renamed_binding.source_var == :signal + @test renamed_binding.source_application_ids == [:signal_source] + @test renamed_binding.carrier_kind == :ref + @test renamed_binding.copy_semantics == :live_references + @test renamed_compiled.application_order == + [:signal_source, :renamed_consumer] + renamed_status = only(model_objects(renamed_input_scene; scale=:Leaf)).status + @test PlantSimEngine.refvalue(renamed_status, :renamed_signal) === + PlantSimEngine.refvalue(renamed_status, :signal) + run!(renamed_input_scene) + @test renamed_status.observed_renamed_signal == 12.5 + + default_scope_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene, status=Status(signal_sum=0.0, temporal_total=0.0)), + Object(:plant_1; scale=:Plant, kind=:plant, parent=:scene, status=Status(signal_sum=0.0, temporal_total=0.0)), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:plant_1, status=Status(leaf_area=1.0)), + Object(:leaf_2; scale=:Leaf, kind=:plant, parent=:plant_1, status=Status(leaf_area=2.0)), + Object(:plant_2; scale=:Plant, kind=:plant, parent=:scene, status=Status(signal_sum=0.0, temporal_total=0.0)), + Object(:leaf_3; scale=:Leaf, kind=:plant, parent=:plant_2, status=Status(leaf_area=3.0)), + ) + plant_default_scope = Advanced.compile_composite_model( + default_scope_scene, + ( + ModelSpec(ModelObjectTemporalSumModel(); name=:plant_leaf_sum) |> + AppliesTo(Many(scale=:Plant)) |> + Inputs(:signal_sum => Many(scale=:Leaf, within=Subtree(), var=:leaf_area)), + ), + ) + @test only(row for row in explain_bindings(plant_default_scope) if row.consumer_id == :plant_1).source_ids == + [:leaf_1, :leaf_2] + @test only(row for row in explain_bindings(plant_default_scope) if row.consumer_id == :plant_2).source_ids == + [:leaf_3] + scene_default_scope = Advanced.compile_composite_model( + default_scope_scene, + ( + ModelSpec(ModelObjectTemporalSumModel(); name=:scene_leaf_sum) |> + AppliesTo(One(scale=:Scene)) |> + Inputs(:signal_sum => Many(scale=:Leaf, var=:leaf_area)), + ), + ) + @test only(explain_bindings(scene_default_scope)).source_ids == [:leaf_1, :leaf_2, :leaf_3] + + inferred_input_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0, observed_signal=0.0)), + ) + inferred_input_specs = ( + ModelSpec(ModelObjectSignalSourceModel(); name=:signal_source) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer) |> + AppliesTo(One(scale=:Leaf)), + ) + inferred_compiled = Advanced.compile_composite_model(inferred_input_scene, inferred_input_specs) + inferred_binding = only(explain_bindings(inferred_compiled)) + @test inferred_binding.application_id == :signal_consumer + @test inferred_binding.input == :signal + @test inferred_binding.origin == :inferred_same_object + @test inferred_binding.source_ids == [:leaf_1] + @test inferred_binding.source_application_ids == [:signal_source] + @test inferred_binding.process == :model_object_signal_source + @test inferred_binding.application == :signal_source + @test inferred_binding.has_reference_carrier + @test inferred_binding.carrier_kind == :ref + @test inferred_binding.copy_semantics == :live_references + inferred_consumer_status = only(model_objects(inferred_input_scene; scale=:Leaf)).status + inferred_compiled_binding = only( + binding for binding in inferred_compiled.input_bindings + if binding.application_id == :signal_consumer + ) + @test PlantSimEngine.refvalue(inferred_consumer_status, :signal) === input_carrier(inferred_compiled_binding) + inferred_input_model_with_apps = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0, observed_signal=0.0)); + applications=inferred_input_specs, + ) + run!(inferred_input_model_with_apps) + @test only(model_objects(inferred_input_model_with_apps; scale=:Leaf)).status.observed_signal == 1.0 + + generated_status_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:signal_source) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + generated_status_compiled = Advanced.refresh_bindings!(generated_status_scene) + generated_status = only(model_objects(generated_status_scene; scale=:Leaf)).status + @test generated_status isa Status + @test Set(propertynames(generated_status)) == + Set((:signal, :observed_signal)) + generated_binding = only( + binding for binding in generated_status_compiled.input_bindings + if binding.application_id == :signal_consumer + ) + @test PlantSimEngine.refvalue(generated_status, :signal) === input_carrier(generated_binding) + run!(generated_status_scene) + @test generated_status.observed_signal == 1.0 + + reversed_dependency_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0, observed_signal=0.0)); + applications=reverse(inferred_input_specs), + ) + reversed_compiled = Advanced.refresh_bindings!(reversed_dependency_scene) + @test length(reversed_compiled.applications_by_id) == length(reversed_compiled.applications) + @test reversed_compiled.applications_by_id[:signal_source].process == :model_object_signal_source + @test reversed_compiled.applications_by_id[:signal_consumer].process == :model_object_signal_consumer + @test reversed_compiled.application_order == [:signal_source, :signal_consumer] + @test [row.application_id for row in explain_schedule(reversed_compiled)] == + [:signal_source, :signal_consumer] + @test [row.execution_index for row in explain_schedule(reversed_compiled)] == [1, 2] + run!(reversed_dependency_scene) + @test only(model_objects(reversed_dependency_scene; scale=:Leaf)).status.observed_signal == 1.0 + + @test_throws ErrorException Advanced.compile_composite_model( + CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(cycle_a=0.0, cycle_b=0.0)), + ), + ( + ModelSpec(ModelObjectCycleAModel(); name=:cycle_a) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectCycleBModel(); name=:cycle_b) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + + lagged_cycle_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(cycle_a=0.0, cycle_b=1.0), + ); + applications=( + ModelSpec(ModelObjectCycleAModel(); name=:cycle_a) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs( + PreviousTimeStep(:cycle_b) => One( + scale=:Leaf, + process=:model_object_cycle_b, + var=:cycle_b, + ), + ), + ModelSpec(ModelObjectCycleBModel(); name=:cycle_b) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + lagged_cycle_compiled = Advanced.refresh_bindings!(lagged_cycle_scene) + @test lagged_cycle_compiled.application_order == [:cycle_a, :cycle_b] + lagged_binding = only( + row for row in explain_bindings(lagged_cycle_compiled) + if row.application_id == :cycle_a && row.input == :cycle_b + ) + @test lagged_binding.policy == PreviousTimeStep(:cycle_b) + @test lagged_binding.carrier_kind == :temporal_stream + @test lagged_binding.copy_semantics == :materialized_temporal_value + lagged_cycle_simulation = run!( + lagged_cycle_scene; + steps=3, + outputs=OutputRequest( + :Leaf, + :cycle_a; + name=:lagged_cycle_a, + application=:cycle_a, + ), + ) + lagged_cycle_status = only(model_objects(lagged_cycle_scene; scale=:Leaf)).status + @test lagged_cycle_status.cycle_a == 11.0 + @test lagged_cycle_status.cycle_b == 22.0 + @test getproperty.( + collect_outputs( + lagged_cycle_simulation, + :leaf_1, + :cycle_a; + sink=nothing, + ), + :value, + ) == [2.0, 5.0, 11.0] + lagged_source_stream = outputs(lagged_cycle_simulation)[ + (:cycle_b, ObjectId(:leaf_1), :cycle_b) + ] + @test getindex.(lagged_source_stream, 1) == [2.0, 3.0] + @test getindex.(lagged_source_stream, 2) == [10.0, 22.0] + @test only( + row for row in explain_output_retention(lagged_cycle_simulation) + if row.application_id == :cycle_b + ).retention_steps == 2.0 + + lagged_external_state_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, parent=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:plant_1, status=Status(signal=4.0)), + Object(:leaf_2; scale=:Leaf, kind=:plant, parent=:plant_1, status=Status(signal=6.0)); + applications=( + ModelSpec(ModelObjectPlantSignalSumModel(); name=:plant_signal_sum) |> + AppliesTo(One(scale=:Plant)) |> + Inputs( + PreviousTimeStep(:signals) => Many( + scale=:Leaf, + within=Subtree(), + var=:signal, + ), + ), + ), + ) + lagged_external_binding = only( + row for row in explain_bindings(Advanced.refresh_bindings!(lagged_external_state_scene)) + if row.application_id == :plant_signal_sum && row.input == :signals + ) + @test lagged_external_binding.carrier_kind == :temporal_stream + @test isempty(lagged_external_binding.source_application_ids) + run!(lagged_external_state_scene; steps=2) + @test only(model_objects(lagged_external_state_scene; scale=:Plant)).status.signal_total == 10.0 + + mismatched_lag_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(cycle_a=0.0, cycle_b=1.0), + ); + applications=( + ModelSpec(ModelObjectCycleAModel(); name=:cycle_a) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs( + :cycle_b => One( + scale=:Leaf, + process=:model_object_cycle_b, + var=:cycle_b, + policy=PreviousTimeStep(:other), + ), + ), + ModelSpec(ModelObjectCycleBModel(); name=:cycle_b) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + @test_throws "PreviousTimeStep marker for input `cycle_b`" Advanced.refresh_bindings!(mismatched_lag_scene) + + @test_throws ErrorException Advanced.compile_composite_model( + CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene), + ), + ( + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + @test_throws ErrorException Advanced.compile_composite_model( + inferred_input_scene, + ( + ModelSpec(ModelObjectSignalSourceModel(); name=:sunlit_signal) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectSignalSourceModel(); name=:shaded_signal) |> + AppliesTo(One(scale=:Leaf)) |> + Updates(:signal; after=:sunlit_signal), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + + filtered_input_specs = ( + ModelSpec(ModelObjectSignalSourceModel(); name=:signal_source) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs(:signal => One(scale=:Leaf, var=:signal, process=:model_object_signal_source, application=:signal_source)), + ) + filtered_binding = only(explain_bindings(Advanced.compile_composite_model(inferred_input_scene, filtered_input_specs))) + @test filtered_binding.origin == :model_spec + @test filtered_binding.source_application_ids == [:signal_source] + @test filtered_binding.process == :model_object_signal_source + @test filtered_binding.application == :signal_source + @test_throws ErrorException Advanced.compile_composite_model( + inferred_input_scene, + ( + filtered_input_specs[1], + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs(:signal => One(scale=:Leaf, var=:signal, application=:missing_source)), + ), + ) + @test_throws ErrorException Advanced.compile_composite_model( + inferred_input_scene, + ( + filtered_input_specs[1], + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs(:siggnal => One(scale=:Leaf, var=:signal, application=:signal_source)), + ), + ) + @test_throws ErrorException Advanced.compile_composite_model( + inferred_input_scene, + ( + filtered_input_specs[1], + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs(:signal => One(scale=:Leaf, var=:missing_signal)), + ), + ) + + carrier_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, species=:oil_palm, parent=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, species=:oil_palm, parent=:plant_1, status=Status(leaf_area=1.0, leaf_token=ModelObjectTaggedValue(1), aPPFD=100.0)), + Object(:leaf_2; scale=:Leaf, kind=:plant, species=:oil_palm, parent=:plant_1, status=Status(leaf_area=2.0, leaf_token=2, aPPFD=100.0)), + Object(:soil; scale=:Soil, kind=:soil, parent=:scene, status=Status(soil_water_content=0.31)), + ) + carrier_specs = ( + ModelSpec(ModelObjectCarrierConsumerModel(); name=:carrier_consumer) |> + AppliesTo(Many(scale=:Leaf)) |> + Inputs( + :leaf_areas => Many(scale=:Leaf, within=SelfPlant(), var=:leaf_area), + :leaf_tokens => Many(scale=:Leaf, within=SelfPlant(), var=:leaf_token), + ), + ModelSpec(ToyAssimModel(); name=:assim) |> + AppliesTo(Many(scale=:Leaf)) |> + Inputs(:soil_water_content => One(scale=:Soil, within=SceneScope(), var=:soil_water_content)), + ) + carrier_compiled = Advanced.compile_composite_model(carrier_scene, carrier_specs) + carrier_rows = explain_bindings(carrier_compiled) + leaf_1_carrier_bindings = carrier_compiled.input_bindings_by_target[(:carrier_consumer, ObjectId(:leaf_1))] + @test length(leaf_1_carrier_bindings) == 2 + @test Set(binding.input for binding in leaf_1_carrier_bindings) == Set((:leaf_areas, :leaf_tokens)) + @test length(carrier_compiled.input_bindings_by_target[(:assim, ObjectId(:leaf_1))]) == 1 + leaf_area_binding = only( + binding for binding in carrier_compiled.input_bindings + if binding.application_id == :carrier_consumer && binding.consumer_id == ObjectId(:leaf_1) && binding.input == :leaf_areas + ) + @test has_reference_carrier(leaf_area_binding) + @test input_carrier(leaf_area_binding) isa PlantSimEngine.RefVector + leaf_2_area_binding = only( + binding for binding in carrier_compiled.input_bindings + if binding.application_id == :carrier_consumer && + binding.consumer_id == ObjectId(:leaf_2) && + binding.input == :leaf_areas + ) + @test input_carrier(leaf_2_area_binding) === input_carrier(leaf_area_binding) + @test leaf_2_area_binding.source_ids === leaf_area_binding.source_ids + @test input_value(leaf_area_binding)[1] == 1.0 + input_value(leaf_area_binding)[1] = 4.0 + leaf_1_object = only(object for object in model_objects(carrier_scene; scale=:Leaf) if object.id == ObjectId(:leaf_1)) + @test leaf_1_object.status.leaf_area == 4.0 + leaf_area_row = only(row for row in carrier_rows if row.application_id == :carrier_consumer && row.consumer_id == :leaf_1 && row.input == :leaf_areas) + @test leaf_area_row.has_reference_carrier + @test leaf_area_row.carrier_kind == :ref_vector + @test leaf_area_row.copy_semantics == :live_references + + token_binding = only( + binding for binding in carrier_compiled.input_bindings + if binding.application_id == :carrier_consumer && binding.consumer_id == ObjectId(:leaf_1) && binding.input == :leaf_tokens + ) + @test input_value(token_binding)[2] == 2 + input_value(token_binding)[2] = 20 + leaf_2_object = only(object for object in model_objects(carrier_scene; scale=:Leaf) if object.id == ObjectId(:leaf_2)) + @test leaf_2_object.status.leaf_token == 20 + token_row = only(row for row in carrier_rows if row.application_id == :carrier_consumer && row.consumer_id == :leaf_1 && row.input == :leaf_tokens) + @test token_row.carrier_kind == :object_ref_vector + @test token_row.copy_semantics == :live_references + + scalar_binding = only( + binding for binding in carrier_compiled.input_bindings + if binding.application_id == :assim && binding.consumer_id == ObjectId(:leaf_1) + ) + @test has_reference_carrier(scalar_binding) + @test input_carrier(scalar_binding) isa Base.RefValue + @test input_value(scalar_binding) == 0.31 + input_carrier(scalar_binding)[] = 0.42 + @test only(model_objects(carrier_scene; scale=:Soil)).status.soil_water_content == 0.42 + scalar_row = only(row for row in carrier_rows if row.application_id == :assim && row.consumer_id == :leaf_1) + @test scalar_row.carrier_kind == :ref + @test scalar_row.copy_semantics == :live_references + + dual_a = ModelObjectDualLike(big"1.25", big"0.5") + dual_b = ModelObjectDualLike(big"2.75", big"1.5") + generic_carrier_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(dual_value=dual_a), + ), + Object( + :leaf_2; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(dual_value=dual_b), + ); + applications=( + ModelSpec(ModelObjectDualLikeSumModel(); name=:dual_sum) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :values => Many( + scale=:Leaf, + within=SceneScope(), + var=:dual_value, + ), + ), + ), + ) + generic_carrier_compiled = Advanced.refresh_bindings!(generic_carrier_scene) + generic_carrier_binding = only(generic_carrier_compiled.input_bindings) + @test input_carrier(generic_carrier_binding) isa + PlantSimEngine.RefVector{ModelObjectDualLike{BigFloat}} + @test eltype(input_carrier(generic_carrier_binding)) == + ModelObjectDualLike{BigFloat} + generic_carrier_sim = run!(generic_carrier_scene; outputs=:all) + generic_model_status = only(model_objects(generic_carrier_scene; scale=:Scene)).status + @test generic_model_status.values === input_carrier(generic_carrier_binding) + @test generic_model_status.total == ModelObjectDualLike(big"4.0", big"2.0") + generic_leaf_1 = + only(object for object in model_objects(generic_carrier_scene; scale=:Leaf) + if object.id == ObjectId(:leaf_1)) + generic_leaf_1.status.dual_value = ModelObjectDualLike(big"3.25", big"2.5") + @test generic_model_status.values[1] == + ModelObjectDualLike(big"3.25", big"2.5") + @test eltype( + outputs(generic_carrier_sim)[ + (:dual_sum, ObjectId(:scene), :total) + ], + ) == Tuple{Float64,ModelObjectDualLike{BigFloat}} + original_generic_carrier = input_carrier(generic_carrier_binding) + register_object!( + generic_carrier_scene, + Object( + :leaf_3; + scale=:Leaf, + kind=:plant, + status=Status(dual_value=ModelObjectDualLike(big"4.5", big"3.5")), + ); + parent=:scene, + ) + extended_generic_compiled = Advanced.refresh_bindings!(generic_carrier_scene) + extended_generic_binding = only(extended_generic_compiled.input_bindings) + @test input_carrier(extended_generic_binding) === original_generic_carrier + @test extended_generic_binding.source_ids == + ObjectId[ObjectId(:leaf_1), ObjectId(:leaf_2), ObjectId(:leaf_3)] + @test input_value(extended_generic_binding)[3] == + ModelObjectDualLike(big"4.5", big"3.5") + + cache_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, species=:oil_palm, name=:palm_1, parent=:scene), + Object(:axis_1; scale=:Axis, kind=:plant, species=:oil_palm, parent=:plant_1), + Object(:leaf_1; scale=:Leaf, kind=:plant, species=:oil_palm, parent=:plant_1), + Object(:leaf_2; scale=:Leaf, kind=:plant, species=:oil_palm, parent=:axis_1), + Object(:plant_2; scale=:Plant, kind=:plant, species=:oil_palm, name=:palm_2, parent=:scene), + Object(:leaf_3; scale=:Leaf, kind=:plant, species=:oil_palm, parent=:plant_2), + Object(:soil; scale=:Soil, kind=:soil, parent=:scene); + applications=compiled_specs, + ) + @test Advanced.bindings_dirty(cache_scene) + cached_a = Advanced.refresh_bindings!(cache_scene) + @test cached_a isa Advanced.CompiledCompositeModel + @test !Advanced.bindings_dirty(cache_scene) + @test Advanced.compiled_bindings(cache_scene) === cached_a + @test cached_a.revision == Advanced.model_revision(cache_scene) + @test Advanced.refresh_bindings!(cache_scene) === cached_a + + register_object!(cache_scene, Object(:leaf_4; scale=:Leaf, kind=:plant, species=:oil_palm); parent=:plant_2) + @test Advanced.bindings_dirty(cache_scene) + @test isnothing(Advanced.compiled_bindings(cache_scene)) + cached_b = Advanced.refresh_bindings!(cache_scene) + @test cached_b !== cached_a + @test cached_b.revision == Advanced.model_revision(cache_scene) + @test only(row for row in explain_applications(cached_b) if row.application_id == :leaf_energy).target_ids == + [:leaf_1, :leaf_2, :leaf_3, :leaf_4] + @test only(row for row in explain_bindings(cached_b) if row.consumer_id == :leaf_3).source_ids == + [:leaf_3, :leaf_4] + + move_object!(cache_scene, :leaf_4, (x=3.0, y=0.0)) + @test !Advanced.bindings_dirty(cache_scene) + @test Advanced.environment_bindings_dirty(cache_scene) + @test Advanced.refresh_bindings!(cache_scene) === cached_b + mark_environment_binding_dirty!(cache_scene) + @test !Advanced.bindings_dirty(cache_scene) + + reparent_object!(cache_scene, :leaf_4, :plant_1) + cached_c = Advanced.refresh_bindings!(cache_scene) + @test only(row for row in explain_bindings(cached_c) if row.consumer_id == :leaf_4).source_ids == + [:leaf_1, :leaf_2, :leaf_4] + + remove_object!(cache_scene, :leaf_4) + cached_d = Advanced.refresh_bindings!(cache_scene) + @test only(row for row in explain_applications(cached_d) if row.application_id == :leaf_energy).target_ids == + [:leaf_1, :leaf_2, :leaf_3] + + grid_backend = ModelObjectGridBackend(Any[]) + environment_specs = ( + ModelSpec(ModelObjectEnvironmentProbeModel(); name=:probe) |> + AppliesTo(Many(scale=:Leaf)) |> + Environment(provider=:grid), + ModelSpec(ModelObjectEnvironmentUpdateModel(); name=:temperature_update) |> + AppliesTo(Many(scale=:Leaf)) |> + Environment(provider=:grid), + ) + environment_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, species=:oil_palm, name=:palm_1, parent=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, species=:oil_palm, parent=:plant_1, geometry=(cell=:cell_a,)), + Object(:leaf_2; scale=:Leaf, kind=:plant, species=:oil_palm, parent=:plant_1, geometry=(cell=:cell_b,)); + applications=environment_specs, + environment=grid_backend, + ) + compiled_environment = Advanced.refresh_environment_bindings!(environment_scene) + @test compiled_environment isa Advanced.CompiledEnvironmentBindings + @test !Advanced.environment_bindings_dirty(environment_scene) + @test Advanced.compiled_environment_bindings(environment_scene) === compiled_environment + @test length(compiled_environment.by_target) == length(compiled_environment.bindings) + @test compiled_environment.by_target[(:probe, ObjectId(:leaf_1))].cell == :cell_a + @test compiled_environment.by_target[(:temperature_update, ObjectId(:leaf_2))].cell == :cell_b + @test length(grid_backend.binds) == 4 + @test length(grid_backend.index_updates) == 1 + @test any(entity -> entity.id == :leaf_1 && entity.geometry == (cell=:cell_a,), grid_backend.index_updates[1]) + @test any(entity -> entity.id == :plant_1 && entity.scale == :Plant, grid_backend.index_updates[1]) + environment_rows = explain_environment_bindings(compiled_environment) + @test length(environment_rows) == 4 + leaf_1_probe = only(row for row in environment_rows if row.application_id == :probe && row.object_id == :leaf_1) + @test leaf_1_probe.provider == :grid + @test leaf_1_probe.cell == :cell_a + @test leaf_1_probe.required_inputs == [:T, :CO2] + @test leaf_1_probe.produced_outputs == Symbol[] + leaf_2_update = only(row for row in environment_rows if row.application_id == :temperature_update && row.object_id == :leaf_2) + @test leaf_2_update.cell == :cell_b + @test leaf_2_update.required_inputs == [:T] + @test leaf_2_update.source_inputs == [:T] + @test leaf_2_update.produced_outputs == [:T] + + missing_global_meteo_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene); + applications=( + ModelSpec(ModelObjectEnvironmentCO2ProbeModel(); name=:co2_probe) |> + AppliesTo(One(scale=:Leaf)) |> + Environment(provider=:global), + ), + environment=(T=20.0,), + ) + @test_throws "co2_probe" validate_meteo_inputs(missing_global_meteo_scene) + @test_throws "Composite model environment is missing required meteo inputs" Advanced.refresh_environment_bindings!(missing_global_meteo_scene) + @test_throws "source `CO2`" Advanced.refresh_environment_bindings!(missing_global_meteo_scene) + + application_environment_backend = + ModelObjectMutableEnvironmentBackend(:cell_a => 23.0) + application_environment_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + geometry=(cell=:cell_a,), + ); + applications=( + ModelSpec(ModelObjectEnvironmentCO2ProbeModel(); name=:local_co2_probe) |> + AppliesTo(One(scale=:Leaf)) |> + Environment(backend=application_environment_backend), + ), + environment=(T=20.0,), + ) + @test validate_meteo_inputs(application_environment_scene) === nothing + @test validate_meteo_inputs(Advanced.refresh_bindings!(application_environment_scene)) === + nothing + @test_throws "CO2" validate_meteo_inputs( + Advanced.refresh_bindings!(application_environment_scene), + (T=20.0,), + ) + + remapped_global_meteo_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene); + applications=( + ModelSpec(ModelObjectEnvironmentCO2ProbeModel(); name=:co2_probe) |> + AppliesTo(One(scale=:Leaf)) |> + Environment(provider=:global, sources=(CO2=:Ca,)), + ), + environment=(T=20.0, Ca=415.0), + ) + @test validate_meteo_inputs(remapped_global_meteo_scene) === nothing + @test_throws "Ca" validate_meteo_inputs( + Advanced.refresh_bindings!(remapped_global_meteo_scene), + (T=20.0, CO2=415.0), + ) + @test validate_meteo_inputs( + Advanced.refresh_bindings!(remapped_global_meteo_scene), + (T=20.0, Ca=415.0), + ) === nothing + remapped_environment = Advanced.refresh_environment_bindings!(remapped_global_meteo_scene) + remapped_row = only(explain_environment_bindings(remapped_environment)) + @test remapped_row.required_inputs == [:T, :CO2] + @test remapped_row.source_inputs == [:T, :Ca] + run!(remapped_global_meteo_scene) + remapped_status = only(model_objects(remapped_global_meteo_scene; scale=:Leaf)).status + @test remapped_status.temperature_seen == 20.0 + @test remapped_status.co2_seen == 415.0 + + hinted_global_meteo_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene); + applications=( + ModelSpec(ModelObjectEnvironmentCO2HintProbeModel(); name=:co2_probe) |> + AppliesTo(One(scale=:Leaf)) |> + Environment(provider=:global), + ), + environment=(T=21.0, Ca=420.0), + ) + @test validate_meteo_inputs(hinted_global_meteo_scene) === nothing + hinted_environment = Advanced.refresh_environment_bindings!(hinted_global_meteo_scene) + hinted_row = only(explain_environment_bindings(hinted_environment)) + @test hinted_row.required_inputs == [:T, :CO2] + @test hinted_row.source_inputs == [:T, :Ca] + hinted_application = Advanced.refresh_bindings!(hinted_global_meteo_scene).applications_by_id[:co2_probe] + @test meteo_bindings(hinted_application.spec).CO2.source == :Ca + run!(hinted_global_meteo_scene) + hinted_status = only(model_objects(hinted_global_meteo_scene; scale=:Leaf)).status + @test hinted_status.temperature_seen == 21.0 + @test hinted_status.co2_seen == 420.0 + + hinted_override_global_meteo_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene); + applications=( + ModelSpec(ModelObjectEnvironmentCO2HintProbeModel(); name=:co2_probe) |> + AppliesTo(One(scale=:Leaf)) |> + Environment(provider=:global, sources=(CO2=:Cb,)), + ), + environment=(T=22.0, Ca=420.0, Cb=430.0), + ) + hinted_override_row = only( + explain_environment_bindings(Advanced.refresh_environment_bindings!(hinted_override_global_meteo_scene)) + ) + @test hinted_override_row.source_inputs == [:T, :Cb] + hinted_override_application = + Advanced.refresh_bindings!(hinted_override_global_meteo_scene).applications_by_id[:co2_probe] + @test meteo_bindings(hinted_override_application.spec).CO2.source == :Cb + @test meteo_bindings(hinted_override_application.spec).CO2.reducer isa MeanReducer + run!(hinted_override_global_meteo_scene) + hinted_override_status = + only(model_objects(hinted_override_global_meteo_scene; scale=:Leaf)).status + @test hinted_override_status.temperature_seen == 22.0 + @test hinted_override_status.co2_seen == 430.0 + + if PlantSimEngine._has_meteo_sampler_api() + windowed_weather = Weather([ + Atmosphere( + T=10.0, + Wind=1.0, + Rh=0.50, + P=100.0, + duration=Hour(1), + Ca=400.0, + Cb=410.0, + ), + Atmosphere( + T=20.0, + Wind=1.0, + Rh=0.60, + P=100.0, + duration=Hour(1), + Ca=410.0, + Cb=420.0, + ), + Atmosphere( + T=30.0, + Wind=1.0, + Rh=0.70, + P=100.0, + duration=Hour(1), + Ca=420.0, + Cb=430.0, + ), + Atmosphere( + T=40.0, + Wind=1.0, + Rh=0.80, + P=100.0, + duration=Hour(1), + Ca=430.0, + Cb=440.0, + ), + ]) + windowed_default_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene), + Object(:leaf_2; scale=:Leaf, kind=:plant, parent=:scene); + applications=( + ModelSpec(ModelObjectTemperatureOnlyProbeModel(); name=:temperature_probe) |> + AppliesTo(Many(scale=:Leaf)) |> + TimeStep(Hour(2)), + ), + environment=windowed_weather, + ) + windowed_default_sim = run!(windowed_default_scene; steps=4, outputs=:all) + for leaf in model_objects(windowed_default_scene; scale=:Leaf) + @test leaf.status.temperature_seen == 25.0 + values = getproperty.( + collect_outputs( + windowed_default_sim, + leaf.id.value, + :temperature_seen; + sink=nothing, + ), + :value, + ) + @test values == [10.0, 25.0] + end + @test isempty(windowed_default_sim.environment_bindings.sample_cache) + @test all( + row -> row.temporal_sampler, + explain_environment_bindings(windowed_default_sim.environment_bindings), + ) + + windowed_override_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene); + applications=( + ModelSpec( + ModelObjectAggregatedEnvironmentProbeModel(); + name=:aggregated_probe, + ) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(2)) |> + Environment(provider=:global, sources=(CO2=:Cb,)), + ), + environment=windowed_weather, + ) + windowed_override_application = + Advanced.refresh_bindings!(windowed_override_scene).applications_by_id[:aggregated_probe] + @test meteo_bindings(windowed_override_application.spec).CO2.source == :Cb + @test meteo_bindings(windowed_override_application.spec).CO2.reducer isa MeanReducer + windowed_override_sim = run!(windowed_override_scene; steps=4, outputs=:all) + temperature_values = getproperty.( + collect_outputs( + windowed_override_sim, + :leaf_1, + :temperature_seen; + sink=nothing, + ), + :value, + ) + co2_values = getproperty.( + collect_outputs( + windowed_override_sim, + :leaf_1, + :co2_seen; + sink=nothing, + ), + :value, + ) + @test temperature_values == [10.0, 30.0] + @test co2_values == [410.0, 425.0] + end + + contract_backend = ModelObjectGridBackend() + contract_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + geometry=(cell=:cell_a,), + ); + applications=( + ModelSpec(ModelObjectEnvironmentProbeModel(); name=:probe) |> + AppliesTo(One(scale=:Leaf)) |> + Environment(provider=:grid), + ), + environment=contract_backend, + ) + contract_compiled = Advanced.refresh_bindings!(contract_scene) + original_contract_bindings = + Advanced.refresh_environment_bindings!(contract_scene, contract_compiled) + original_contract_binding = + original_contract_bindings.by_target[(:probe, ObjectId(:leaf_1))] + @test original_contract_binding.required_inputs == [:T, :CO2] + @test length(contract_backend.binds) == 1 + @test length(contract_backend.index_updates) == 1 + + revised_contract_compiled = Advanced.compile_composite_model( + contract_scene, + ( + ModelSpec(ModelObjectTemperatureOnlyProbeModel(); name=:probe) |> + AppliesTo(One(scale=:Leaf)) |> + Environment(provider=:grid), + ), + ) + revised_contract_bindings = + Advanced.refresh_environment_bindings!(contract_scene, revised_contract_compiled) + revised_contract_binding = + revised_contract_bindings.by_target[(:probe, ObjectId(:leaf_1))] + @test revised_contract_binding.required_inputs == [:T] + @test revised_contract_binding.cell == original_contract_binding.cell + @test revised_contract_binding !== original_contract_binding + @test length(contract_backend.binds) == 1 + @test length(contract_backend.index_updates) == 1 + @test Advanced.refresh_environment_bindings!( + contract_scene, + revised_contract_compiled, + ) === revised_contract_bindings + + structural_environment_cache = Advanced.refresh_bindings!(environment_scene) + move_object!(environment_scene, :leaf_2, (cell=:cell_c,)) + @test !Advanced.bindings_dirty(environment_scene) + @test Advanced.environment_bindings_dirty(environment_scene) + @test Advanced.refresh_bindings!(environment_scene) === structural_environment_cache + refreshed_environment = Advanced.refresh_environment_bindings!(environment_scene) + @test !Advanced.environment_bindings_dirty(environment_scene) + @test length(grid_backend.binds) == 6 + @test length(grid_backend.index_updates) == 2 + @test any(entity -> entity.id == :leaf_2 && entity.geometry == (cell=:cell_c,), grid_backend.index_updates[2]) + @test only(row for row in explain_environment_bindings(refreshed_environment) if row.application_id == :probe && row.object_id == :leaf_2).cell == :cell_c + + update_geometry!(environment_scene, :leaf_1, (cell=:cell_e,); invalidate_environment=false) + @test geometry(only(object for object in model_objects(environment_scene; scale=:Leaf) if object.id == ObjectId(:leaf_1))) == (cell=:cell_e,) + @test !Advanced.environment_bindings_dirty(environment_scene) + mark_environment_binding_dirty!(environment_scene, :leaf_1) + @test Advanced.environment_bindings_dirty(environment_scene) + refreshed_after_mark = Advanced.refresh_environment_bindings!(environment_scene) + @test !Advanced.environment_bindings_dirty(environment_scene) + @test length(grid_backend.binds) == 8 + @test length(grid_backend.index_updates) == 3 + @test any(entity -> entity.id == :leaf_1 && entity.geometry == (cell=:cell_e,), grid_backend.index_updates[3]) + @test only(row for row in explain_environment_bindings(refreshed_after_mark) if row.application_id == :probe && row.object_id == :leaf_1).cell == :cell_e + + register_object!(environment_scene, Object(:leaf_3; scale=:Leaf, kind=:plant, species=:oil_palm, geometry=(cell=:cell_d,)); parent=:plant_1) + @test Advanced.bindings_dirty(environment_scene) + @test Advanced.environment_bindings_dirty(environment_scene) + refreshed_with_new_leaf = Advanced.refresh_environment_bindings!(environment_scene) + @test length(grid_backend.binds) == 14 + @test length(grid_backend.index_updates) == 4 + @test any(entity -> entity.id == :leaf_3 && entity.geometry == (cell=:cell_d,), grid_backend.index_updates[4]) + @test only(row for row in explain_applications(Advanced.refresh_bindings!(environment_scene)) if row.application_id == :probe).target_ids == + [:leaf_1, :leaf_2, :leaf_3] + @test only(row for row in explain_environment_bindings(refreshed_with_new_leaf) if row.application_id == :probe && row.object_id == :leaf_3).cell == :cell_d + + inherited_grid_backend = ModelObjectGridBackend() + inherited_environment_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :plant_1; + scale=:Plant, + kind=:plant, + parent=:scene, + geometry=(cell=:cell_a,), + ), + Object(:inherited_leaf; scale=:Leaf, kind=:plant, parent=:plant_1), + Object( + :positioned_leaf; + scale=:Leaf, + kind=:plant, + parent=:plant_1, + geometry=(cell=:cell_c,), + ); + applications=( + ModelSpec(ModelObjectEnvironmentProbeModel(); name=:inherited_probe) |> + AppliesTo(Many(scale=:Leaf)) |> + Environment(provider=:grid), + ), + environment=inherited_grid_backend, + ) + inherited_bindings = Advanced.refresh_environment_bindings!(inherited_environment_scene) + inherited_rows = explain_environment_bindings(inherited_bindings) + inherited_row = only(row for row in inherited_rows if row.object_id == :inherited_leaf) + positioned_row = only(row for row in inherited_rows if row.object_id == :positioned_leaf) + @test inherited_row.cell == :cell_a + @test inherited_row.geometry_source == :ancestor + @test inherited_row.geometry_source_object_id == :plant_1 + @test positioned_row.cell == :cell_c + @test positioned_row.geometry_source == :self + positioned_binding = inherited_bindings.by_target[ + (:inherited_probe, ObjectId(:positioned_leaf)) + ] + + update_geometry!(inherited_environment_scene, :plant_1, (cell=:cell_b,)) + @test Advanced.environment_bindings_dirty(inherited_environment_scene) + refreshed_inherited_bindings = + Advanced.refresh_environment_bindings!(inherited_environment_scene) + refreshed_inherited_rows = explain_environment_bindings(refreshed_inherited_bindings) + @test only( + row for row in refreshed_inherited_rows if row.object_id == :inherited_leaf + ).cell == :cell_b + @test refreshed_inherited_bindings.by_target[ + (:inherited_probe, ObjectId(:positioned_leaf)) + ] === positioned_binding + + mutable_environment_backend = ModelObjectMutableEnvironmentBackend(:cell_a => 20.0, :cell_b => 30.0) + mutable_environment_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, geometry=(cell=:cell_a,), status=Status(T=0.0, temperature_update=0.0, temperature_seen=0.0)), + Object(:leaf_2; scale=:Leaf, kind=:plant, parent=:scene, geometry=(cell=:cell_b,), status=Status(T=0.0, temperature_update=0.0, temperature_seen=0.0)); + applications=( + ModelSpec(ModelObjectEnvironmentUpdateModel(); name=:temperature_update_runtime) |> + AppliesTo(Many(scale=:Leaf)) |> + Environment(provider=:grid), + ModelSpec(ModelObjectEnvironmentProbeModel(); name=:probe_after_update) |> + AppliesTo(Many(scale=:Leaf)) |> + Environment(provider=:grid), + ), + environment=mutable_environment_backend, + ) + run!(mutable_environment_scene) + @test mutable_environment_backend.values == Dict(:cell_a => 21.0, :cell_b => 31.0) + @test mutable_environment_backend.writes == [ + (application=:temperature_update_runtime, process=:model_object_environment_update, cell=:cell_a, variable=:T, value=21.0, time=1), + (application=:temperature_update_runtime, process=:model_object_environment_update, cell=:cell_b, variable=:T, value=31.0, time=1), + ] + mutable_environment_statuses = Dict(object.id.value => object.status for object in model_objects(mutable_environment_scene; scale=:Leaf)) + @test mutable_environment_statuses[:leaf_1].temperature_seen == 21.0 + @test mutable_environment_statuses[:leaf_2].temperature_seen == 31.0 + + runtime_model = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, parent=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:plant_1, status=Status(leaf_area=1.5, leaf_areas=[0.0], leaf_tokens=Any[], carrier_total=0.0, temperature_seen=0.0)), + Object(:leaf_2; scale=:Leaf, kind=:plant, parent=:plant_1, status=Status(leaf_area=2.5, leaf_areas=[0.0], leaf_tokens=Any[], carrier_total=0.0, temperature_seen=0.0)); + applications=( + ModelSpec(ModelObjectCarrierConsumerModel(); name=:carrier_runtime) |> + AppliesTo(Many(scale=:Leaf)) |> + Inputs(:leaf_areas => Many(scale=:Leaf, within=SelfPlant(), var=:leaf_area)), + ModelSpec(ModelObjectEnvironmentProbeModel(); name=:probe_runtime) |> + AppliesTo(Many(scale=:Leaf)) |> + Environment(provider=:global), + ), + environment=(T=27.5, CO2=410.0), + ) + run!(runtime_model) + @test all(object.status.carrier_total == 4.0 for object in model_objects(runtime_model; scale=:Leaf)) + @test all(object.status.temperature_seen == 27.5 for object in model_objects(runtime_model; scale=:Leaf)) + runtime_compiled = Advanced.refresh_bindings!(runtime_model) + runtime_application = runtime_compiled.applications_by_id[:carrier_runtime] + runtime_object_id = ObjectId(:leaf_1) + PlantSimEngine._materialize_model_inputs!( + runtime_compiled, + runtime_application, + runtime_object_id, + nothing, + 1, + ) + runtime_status = PlantSimEngine._model_object_status(runtime_model, runtime_object_id) + runtime_bindings = runtime_compiled.input_bindings_by_target[ + (:carrier_runtime, runtime_object_id) + ] + runtime_binding = only(runtime_bindings) + @test runtime_status.leaf_areas === input_carrier(runtime_binding) + runtime_leaf_2 = only( + object for object in model_objects(runtime_model; scale=:Leaf) + if object.id == ObjectId(:leaf_2) + ) + runtime_leaf_2.status.leaf_area = 7.0 + @test runtime_status.leaf_areas[2] == 7.0 + @test @allocated( + PlantSimEngine._materialize_model_inputs!( + runtime_compiled, + runtime_application, + runtime_object_id, + nothing, + 1, + ) + ) == 0 + + call_runtime_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0, called_signal=0.0)); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:signal_source) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectSignalCallerModel(); name=:signal_caller) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + run!(call_runtime_scene) + call_status = only(model_objects(call_runtime_scene; scale=:Leaf)).status + @test call_status.signal == 1.0 + @test call_status.called_signal == 1.0 + call_schedule = explain_schedule(Advanced.refresh_bindings!(call_runtime_scene)) + @test only(row for row in call_schedule if row.application_id == :signal_source).manual_call_only + @test !only(row for row in call_schedule if row.application_id == :signal_source).root_scheduled + @test only(row for row in call_schedule if row.application_id == :signal_caller).root_scheduled + + hard_call_meteo_backend = ModelObjectMutableEnvironmentBackend(:cell_a => 20.0) + hard_call_meteo_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + geometry=(cell=:cell_a,), + status=Status(T=0.0, temperature_seen=0.0, called_temperature=0.0), + ); + applications=( + ModelSpec(ModelObjectMeteoCallSourceModel(); name=:meteo_source) |> + AppliesTo(One(scale=:Leaf)) |> + Environment(provider=:grid), + ModelSpec(ModelObjectMeteoCallControllerModel(31.5, false); name=:meteo_controller) |> + AppliesTo(One(scale=:Leaf)) |> + Calls(:source => One(scale=:Leaf, process=:model_object_meteo_call_source)), + ), + environment=hard_call_meteo_backend, + ) + run!(hard_call_meteo_scene) + hard_call_meteo_status = only(model_objects(hard_call_meteo_scene; scale=:Leaf)).status + @test hard_call_meteo_status.temperature_seen == 31.5 + @test hard_call_meteo_status.called_temperature == 31.5 + @test hard_call_meteo_backend.values[:cell_a] == 20.0 + @test isempty(hard_call_meteo_backend.writes) + + publish_hard_call_meteo_backend = ModelObjectMutableEnvironmentBackend(:cell_a => 20.0) + publish_hard_call_meteo_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + geometry=(cell=:cell_a,), + status=Status(T=0.0, temperature_seen=0.0, called_temperature=0.0), + ); + applications=( + ModelSpec(ModelObjectMeteoCallSourceModel(); name=:meteo_source) |> + AppliesTo(One(scale=:Leaf)) |> + Environment(provider=:grid), + ModelSpec(ModelObjectMeteoCallControllerModel(32.5, true); name=:meteo_controller) |> + AppliesTo(One(scale=:Leaf)) |> + Calls(:source => One(scale=:Leaf, process=:model_object_meteo_call_source)), + ), + environment=publish_hard_call_meteo_backend, + ) + run!(publish_hard_call_meteo_scene) + publish_hard_call_meteo_status = only(model_objects(publish_hard_call_meteo_scene; scale=:Leaf)).status + @test publish_hard_call_meteo_status.temperature_seen == 32.5 + @test publish_hard_call_meteo_status.called_temperature == 32.5 + @test publish_hard_call_meteo_backend.values[:cell_a] == 33.5 + @test publish_hard_call_meteo_backend.writes == [ + (application=:meteo_source, process=:model_object_meteo_call_source, cell=:cell_a, variable=:T, value=33.5, time=1), + ] + + iterative_hard_call_backend = ModelObjectMutableEnvironmentBackend(:cell_a => 20.0) + iterative_hard_call_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + geometry=(cell=:cell_a,), + status=Status(T=0.0, temperature_seen=0.0, called_temperature=0.0), + ); + applications=( + ModelSpec(ModelObjectMeteoCallSourceModel(); name=:meteo_source) |> + AppliesTo(One(scale=:Leaf)) |> + Environment(provider=:grid), + ModelSpec( + ModelObjectIterativeMeteoCallControllerModel((30.0, 31.0), 32.0); + name=:meteo_controller, + ) |> + AppliesTo(One(scale=:Leaf)) |> + Calls(:source => One(scale=:Leaf, process=:model_object_meteo_call_source)), + ), + environment=iterative_hard_call_backend, + ) + iterative_hard_call_sim = run!(iterative_hard_call_scene; outputs=:all) + iterative_hard_call_status = + only(model_objects(iterative_hard_call_scene; scale=:Leaf)).status + @test iterative_hard_call_status.temperature_seen == 32.0 + @test iterative_hard_call_status.called_temperature == 32.0 + @test iterative_hard_call_backend.values[:cell_a] == 33.0 + @test iterative_hard_call_backend.writes == [ + ( + application=:meteo_source, + process=:model_object_meteo_call_source, + cell=:cell_a, + variable=:T, + value=33.0, + time=1, + ), + ] + accepted_call_samples = outputs(iterative_hard_call_sim)[ + (:meteo_source, ObjectId(:leaf_1), :temperature_seen) + ] + @test length(accepted_call_samples) == 1 + @test only(accepted_call_samples) == (1.0, 32.0) + + hard_call_order_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0, called_signal=0.0, observed_signal=0.0)); + applications=( + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectSignalSourceModel(); name=:signal_source) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectSignalCallerModel(); name=:signal_caller) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + hard_call_order = Advanced.refresh_bindings!(hard_call_order_scene) + @test hard_call_order.applications_by_id[:signal_caller].process == :model_object_signal_caller + @test hard_call_order.application_order == [:signal_source, :signal_caller, :signal_consumer] + run!(hard_call_order_scene) + hard_call_order_status = only(model_objects(hard_call_order_scene; scale=:Leaf)).status + @test hard_call_order_status.signal == 1.0 + @test hard_call_order_status.observed_signal == 1.0 + + temporal_input_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene, status=Status(signal_sum=0.0, temporal_total=0.0)), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0)); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:hourly_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:scene_temporal_sum) |> + AppliesTo(One(scale=:Scene)) |> + Inputs(:signal_sum => One(scale=:Leaf, var=:signal, policy=Integrate(), window=Hour(2))) |> + TimeStep(Hour(2)), + ), + environment=(duration=Hour(1),), + ) + temporal_binding = only( + row for row in explain_bindings(Advanced.refresh_bindings!(temporal_input_scene)) + if row.application_id == :scene_temporal_sum && row.input == :signal_sum + ) + @test temporal_binding.carrier_hint == :temporal_stream + temporal_input_simulation = run!(temporal_input_scene; steps=3, outputs=:all) + @test temporal_input_simulation isa Simulation + @test temporal_input_simulation.model === temporal_input_scene + @test temporal_input_simulation.compiled isa Advanced.CompiledCompositeModel + @test only(model_objects(temporal_input_scene; scale=:Leaf)).status.signal == 3.0 + @test only(model_objects(temporal_input_scene; scale=:Scene)).status.temporal_total == 5.0 + temporal_output_rows = collect_outputs(temporal_input_simulation; sink=nothing) + @test length(temporal_output_rows) == 5 + @test size(collect_outputs(temporal_input_simulation), 1) == 5 + @test count(row -> row.object_id == :leaf_1 && row.variable == :signal, temporal_output_rows) == 3 + @test count(row -> row.object_id == :scene && row.variable == :temporal_total, temporal_output_rows) == 2 + @test collect_outputs(temporal_input_simulation, :leaf_1, :signal; sink=nothing)[end].value == 3.0 + temporal_output_summary = explain_outputs(temporal_input_simulation) + @test only(row for row in temporal_output_summary if row.object_id == :leaf_1 && row.variable == :signal).nsamples == 3 + @test only(row for row in temporal_output_summary if row.object_id == :scene && row.variable == :temporal_total).application_id == :scene_temporal_sum + + tracked_output_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(signal=0.0, observed_signal=0.0), + ); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:hourly_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(1)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_observer) |> + AppliesTo(One(scale=:Leaf)), + ), + environment=(duration=Hour(1),), + ) + tracked_output_request = OutputRequest( + :Leaf, + :signal; + name=:signal_two_hour, + process=:model_object_signal_source, + policy=Integrate(), + clock=Hour(2), + ) + tracked_output_simulation = run!( + tracked_output_scene; + steps=3, + outputs=tracked_output_request, + ) + tracked_output_rows = collect_outputs( + tracked_output_simulation, + :signal_two_hour; + sink=nothing, + ) + @test getproperty.(tracked_output_rows, :value) == [1.0, 5.0] + @test getproperty.(tracked_output_rows, :time) == [1.0, 3.0] + @test all(row -> row.object_id == :leaf_1, tracked_output_rows) + @test all(row -> row.application_id == :hourly_signal, tracked_output_rows) + @test Set(keys(outputs(tracked_output_simulation))) == Set([ + (:hourly_signal, ObjectId(:leaf_1), :signal), + ]) + @test explain_output_retention(tracked_output_simulation) == [ + ( + application_id=:hourly_signal, + variable=:signal, + reasons=(:output_request,), + retention_steps=nothing, + current_target_count=1, + ), + ] + @test collect_outputs(tracked_output_simulation; sink=nothing)[:signal_two_hour] == + tracked_output_rows + tracked_output_frames = collect_outputs(tracked_output_simulation) + @test sort(collect(keys(tracked_output_frames))) == [:signal_two_hour] + @test tracked_output_frames[:signal_two_hour][!, :value] == [1.0, 5.0] + @test tracked_output_frames[:signal_two_hour][!, :application_id] == + [:hourly_signal, :hourly_signal] + @test_throws "No model output request named `missing_request`" collect_outputs( + tracked_output_simulation, + :missing_request; + sink=nothing, + ) + + auto_tracked_output_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0)); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:hourly_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(1)), + ), + environment=(duration=Hour(1),), + ) + auto_tracked_output_simulation = run!( + auto_tracked_output_scene; + steps=3, + outputs=OutputRequest(:Leaf, :signal; name=:signal_auto), + ) + auto_tracked_rows = collect_outputs( + auto_tracked_output_simulation, + :signal_auto; + sink=nothing, + ) + @test getproperty.(auto_tracked_rows, :value) == [1.0, 2.0, 3.0] + @test all(row -> row.application_id == :hourly_signal, auto_tracked_rows) + + empty_retention_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(signal=0.0, observed_signal=0.0), + ); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:signal_source) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_observer) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + empty_retention_simulation = run!( + empty_retention_scene; + outputs=:none, + ) + @test isempty(outputs(empty_retention_simulation)) + @test isempty(explain_output_retention(empty_retention_simulation)) + @test only(model_objects(empty_retention_scene; scale=:Leaf)).status.observed_signal == + 1.0 + + selective_temporal_scene = CompositeModel( + Object( + :scene; + scale=:Scene, + kind=:scene, + status=Status(signal_sum=0.0, temporal_total=0.0), + ), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(signal=0.0), + ); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:hourly_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:scene_temporal_sum) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :signal_sum => One( + scale=:Leaf, + var=:signal, + policy=Integrate(), + window=Hour(2), + ), + ) |> + TimeStep(Hour(2)), + ), + environment=(duration=Hour(1),), + ) + selective_temporal_request = OutputRequest( + :Scene, + :temporal_total; + name=:temporal_total_two_hour, + process=:model_object_temporal_sum, + policy=HoldLast(), + clock=Hour(2), + ) + selective_temporal_simulation = run!( + selective_temporal_scene; + steps=3, + outputs=selective_temporal_request, + ) + @test Set(keys(outputs(selective_temporal_simulation))) == Set([ + (:hourly_signal, ObjectId(:leaf_1), :signal), + (:scene_temporal_sum, ObjectId(:scene), :temporal_total), + ]) + selective_retention_rows = explain_output_retention( + selective_temporal_simulation, + ) + @test only( + row for row in selective_retention_rows + if row.application_id == :hourly_signal + ).reasons == (:temporal_dependency,) + @test only( + row for row in selective_retention_rows + if row.application_id == :hourly_signal + ).retention_steps == 2.0 + @test only( + row for row in selective_retention_rows + if row.application_id == :scene_temporal_sum + ).reasons == (:output_request,) + @test isnothing( + only( + row for row in selective_retention_rows + if row.application_id == :scene_temporal_sum + ).retention_steps, + ) + @test getproperty.( + collect_outputs( + selective_temporal_simulation, + :temporal_total_two_hour; + sink=nothing, + ), + :value, + ) == [1.0, 5.0] + + bounded_temporal_scene = CompositeModel( + Object( + :scene; + scale=:Scene, + kind=:scene, + status=Status(signal_sum=0.0, temporal_total=0.0), + ), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(signal=0.0), + ); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:hourly_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:scene_temporal_sum) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :signal_sum => One( + scale=:Leaf, + var=:signal, + policy=Integrate(), + window=Hour(2), + ), + ) |> + TimeStep(Hour(2)), + ), + environment=(duration=Hour(1),), + ) + bounded_temporal_request = OutputRequest( + :Scene, + :temporal_total; + name=:bounded_temporal_total, + application=:scene_temporal_sum, + policy=HoldLast(), + clock=Hour(2), + ) + bounded_temporal_simulation = run!( + bounded_temporal_scene; + steps=19, + outputs=bounded_temporal_request, + ) + bounded_source_samples = outputs(bounded_temporal_simulation)[ + (:hourly_signal, ObjectId(:leaf_1), :signal) + ] + @test length(bounded_source_samples) == 2 + @test getindex.(bounded_source_samples, 1) == [18.0, 19.0] + @test getindex.(bounded_source_samples, 2) == [18.0, 19.0] + @test only(model_objects(bounded_temporal_scene; scale=:Scene)).status.temporal_total == + 37.0 + @test length( + collect_outputs( + bounded_temporal_simulation, + :bounded_temporal_total; + sink=nothing, + ), + ) == 10 + + temporal_holdlast_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene, status=Status(signal_sum=0.0, temporal_total=0.0)), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0)); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:hourly_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:scene_temporal_latest) |> + AppliesTo(One(scale=:Scene)) |> + Inputs(:signal_sum => One(scale=:Leaf, var=:signal, policy=HoldLast(), window=Hour(2))) |> + TimeStep(Hour(2)), + ), + environment=(duration=Hour(1),), + ) + temporal_holdlast_simulation = run!( + temporal_holdlast_scene; + steps=9, + outputs=OutputRequest( + :Scene, + :temporal_total; + name=:holdlast_total, + application=:scene_temporal_latest, + ), + ) + @test only(model_objects(temporal_holdlast_scene; scale=:Scene)).status.temporal_total == 9.0 + @test length( + outputs(temporal_holdlast_simulation)[ + (:hourly_signal, ObjectId(:leaf_1), :signal) + ], + ) == 1 + + trait_policy_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene, status=Status(signal_sum=0.0, temporal_total=0.0)), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0)); + applications=( + ModelSpec(ModelObjectTraitPolicySignalModel(); name=:trait_policy_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:trait_policy_consumer) |> + AppliesTo(One(scale=:Scene)) |> + Inputs(:signal_sum => One(scale=:Leaf, var=:signal)) |> + TimeStep(Hour(2)), + ), + environment=(duration=Hour(1),), + ) + trait_policy_binding = only( + row for row in explain_bindings(Advanced.refresh_bindings!(trait_policy_scene)) + if row.application_id == :trait_policy_consumer && row.input == :signal_sum + ) + @test trait_policy_binding.policy isa Aggregate + @test trait_policy_binding.carrier_hint == :temporal_stream + trait_policy_simulation = run!(trait_policy_scene; steps=3, outputs=:all) + @test only(model_objects(trait_policy_scene; scale=:Scene)).status.temporal_total == 2.5 + @test getproperty.( + collect_outputs( + trait_policy_simulation, + :scene, + :temporal_total; + sink=nothing, + ), + :value, + ) == [1.0, 2.5] + + explicit_policy_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene, status=Status(signal_sum=0.0, temporal_total=0.0)), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0)); + applications=( + ModelSpec(ModelObjectTraitPolicySignalModel(); name=:trait_policy_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:explicit_policy_consumer) |> + AppliesTo(One(scale=:Scene)) |> + Inputs(:signal_sum => One(scale=:Leaf, var=:signal, policy=Integrate())) |> + TimeStep(Hour(2)), + ), + environment=(duration=Hour(1),), + ) + explicit_policy_binding = only( + row for row in explain_bindings(Advanced.refresh_bindings!(explicit_policy_scene)) + if row.application_id == :explicit_policy_consumer && row.input == :signal_sum + ) + @test explicit_policy_binding.policy isa Integrate + run!(explicit_policy_scene; steps=3) + @test only(model_objects(explicit_policy_scene; scale=:Scene)).status.temporal_total == 5.0 + + generic_integrate_scene = CompositeModel( + Object( + :scene; + scale=:Scene, + kind=:scene, + status=Status(signal_sum=big"0.0", temporal_total=big"0.0"), + ), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(signal=big"0.0"), + ); + applications=( + ModelSpec(ModelObjectTimeSignalModel(big"0.0"); name=:big_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:big_integral) |> + AppliesTo(One(scale=:Scene)) |> + Inputs( + :signal_sum => One( + scale=:Leaf, + process=:model_object_signal_source, + var=:signal, + policy=Integrate(), + window=Hour(2), + ), + ) |> + TimeStep(Hour(2)), + ), + environment=(duration=Hour(1),), + ) + generic_integrate_simulation = run!(generic_integrate_scene; steps=3, outputs=:all) + generic_integrate_values = getproperty.( + collect_outputs( + generic_integrate_simulation, + :scene, + :temporal_total; + sink=nothing, + ), + :value, + ) + @test generic_integrate_values == BigFloat[1, 5] + @test all(value -> value isa BigFloat, generic_integrate_values) + + interpolation_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(signal=big"0.0", observed_signal=big"0.0"), + ); + applications=( + ModelSpec(ModelObjectTimeSignalModel(big"0.0"); name=:slow_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(2)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:fast_consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs( + :signal => One( + scale=:Leaf, + process=:model_object_signal_source, + var=:signal, + policy=Interpolate(), + ), + ) |> + TimeStep(Hour(1)), + ), + environment=(duration=Hour(1),), + ) + interpolation_simulation = run!( + interpolation_scene; + steps=5, + outputs=OutputRequest( + :Leaf, + :observed_signal; + name=:interpolated_signal, + application=:fast_consumer, + ), + ) + interpolation_stream = outputs(interpolation_simulation)[ + (:slow_signal, ObjectId(:leaf_1), :signal) + ] + @test eltype(interpolation_stream) == Tuple{Float64,BigFloat} + @test getindex.(interpolation_stream, 1) == [3.0, 5.0] + interpolated_values = getproperty.( + collect_outputs( + interpolation_simulation, + :leaf_1, + :observed_signal; + sink=nothing, + ), + :value, + ) + @test interpolated_values == BigFloat[1, 1, 3, 4, 5] + @test all(value -> value isa BigFloat, interpolated_values) + + interpolation_hold_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(signal=0.0, observed_signal=0.0), + ); + applications=( + ModelSpec(ModelObjectTimeSignalModel(0.0); name=:slow_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(2)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:fast_consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs( + :signal => One( + scale=:Leaf, + process=:model_object_signal_source, + var=:signal, + policy=Interpolate(; mode=:hold, extrapolation=:hold), + ), + ) |> + TimeStep(Hour(1)), + ), + environment=(duration=Hour(1),), + ) + interpolation_hold_simulation = run!(interpolation_hold_scene; steps=6, outputs=:all) + @test getproperty.( + collect_outputs( + interpolation_hold_simulation, + :leaf_1, + :observed_signal; + sink=nothing, + ), + :value, + ) == [1.0, 1.0, 3.0, 3.0, 5.0, 5.0] + + invalid_interpolation_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + status=Status(signal=0.0, observed_signal=0.0), + ); + applications=( + ModelSpec(ModelObjectTimeSignalModel(0.0); name=:signal_source) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs( + :signal => One( + scale=:Leaf, + process=:model_object_signal_source, + var=:signal, + policy=Interpolate(:spline), + ), + ), + ), + ) + @test_throws "Invalid interpolation mode `spline`" Advanced.refresh_bindings!(invalid_interpolation_scene) + + stream_only_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0, observed_signal=0.0)); + applications=( + ModelSpec(ModelObjectSignalSetModel(10.0); name=:stream_signal) |> + AppliesTo(One(scale=:Leaf)) |> + OutputRouting(; signal=:stream_only), + ModelSpec(ModelObjectSignalSetModel(1.0); name=:canonical_signal) |> + AppliesTo(One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer) |> + AppliesTo(One(scale=:Leaf)), + ), + ) + stream_only_compiled = Advanced.refresh_bindings!(stream_only_scene) + stream_only_writer = only(row for row in explain_writers(stream_only_compiled) if row.variable == :signal) + @test stream_only_writer.application_ids == [:canonical_signal] + stream_only_binding = only(row for row in explain_bindings(stream_only_compiled) if row.application_id == :signal_consumer) + @test stream_only_binding.source_application_ids == [:canonical_signal] + stream_only_simulation = run!(stream_only_scene; outputs=:all) + stream_only_status = only(model_objects(stream_only_scene; scale=:Leaf)).status + @test stream_only_status.observed_signal == 1.0 + signal_rows = collect_outputs(stream_only_simulation, :leaf_1, :signal; sink=nothing) + @test Dict(row.application_id => row.value for row in signal_rows) == + Dict(:stream_signal => 10.0, :canonical_signal => 1.0) + @test Set(row.application_id for row in explain_outputs(stream_only_simulation) if row.variable == :signal) == + Set([:stream_signal, :canonical_signal]) + stream_only_only_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0)); + applications=( + ModelSpec(ModelObjectSignalSetModel(10.0); name=:stream_signal) |> + AppliesTo(One(scale=:Leaf)) |> + OutputRouting(; signal=:stream_only), + ), + ) + @test_throws "No model output publisher found" run!( + stream_only_only_scene; + outputs=OutputRequest(:Leaf, :signal; name=:stream_signal_auto_fail), + ) + stream_only_requested = run!( + stream_only_scene; + outputs=OutputRequest(:Leaf, :signal; name=:canonical_signal_request), + ) + stream_only_requested_rows = collect_outputs( + stream_only_requested, + :canonical_signal_request; + sink=nothing, + ) + @test getproperty.(stream_only_requested_rows, :application_id) == [:canonical_signal] + @test getproperty.(stream_only_requested_rows, :value) == [1.0] + explicit_stream_application = run!( + stream_only_scene; + outputs=OutputRequest( + :Leaf, + :signal; + name=:stream_signal_by_application, + application=:stream_signal, + ), + ) + explicit_stream_rows = collect_outputs( + explicit_stream_application, + :stream_signal_by_application; + sink=nothing, + ) + @test getproperty.(explicit_stream_rows, :application_id) == [:stream_signal] + @test getproperty.(explicit_stream_rows, :value) == [10.0] + explicit_canonical_application = run!( + stream_only_scene; + outputs=OutputRequest( + :Leaf, + :signal; + name=:canonical_signal_by_application, + application=:canonical_signal, + ), + ) + explicit_canonical_rows = collect_outputs( + explicit_canonical_application, + :canonical_signal_by_application; + sink=nothing, + ) + @test getproperty.(explicit_canonical_rows, :application_id) == + [:canonical_signal] + @test getproperty.(explicit_canonical_rows, :value) == [1.0] + @test_throws "application `missing_signal`" run!( + stream_only_scene; + outputs=OutputRequest( + :Leaf, + :signal; + name=:missing_signal_application, + application=:missing_signal, + ), + ) + @test_throws "Ambiguous model output publishers" run!( + stream_only_scene; + outputs=OutputRequest( + :Leaf, + :signal; + name=:ambiguous_signal_request, + process=:model_object_signal_source, + ), + ) + + writer_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(biomass=-1.0)), + ) + biomass_source = + ModelSpec(ModelObjectBiomassSourceModel(); name=:carbon_allocation) |> + AppliesTo(One(scale=:Leaf)) + biomass_pruner = + ModelSpec(ModelObjectBiomassPrunerModel(); name=:leaf_pruning) |> + AppliesTo(One(scale=:Leaf)) + + @test_throws ErrorException Advanced.compile_composite_model(writer_scene, (biomass_source, biomass_pruner)) + @test_throws ErrorException Advanced.compile_composite_model( + writer_scene, + (biomass_source, biomass_pruner |> Updates(:biomass; after=:water_status)), + ) + @test_throws ErrorException Advanced.compile_composite_model( + writer_scene, + (biomass_pruner |> Updates(:biomass; after=:carbon_allocation), biomass_source), + ) + + ordered_pruner = biomass_pruner |> Updates(:biomass; after=:carbon_allocation) + writer_compiled = Advanced.compile_composite_model(writer_scene, (biomass_source, ordered_pruner)) + writer_row = only(row for row in explain_writers(writer_compiled) if row.variable == :biomass) + @test writer_row.object_id == :leaf_1 + @test writer_row.duplicate + @test writer_row.application_ids == [:carbon_allocation, :leaf_pruning] + @test writer_row.update_application_ids == [:leaf_pruning] + @test writer_row.update_after == [:leaf_pruning => [:carbon_allocation]] + + writer_runtime_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(biomass=-1.0)); + applications=(biomass_source, ordered_pruner), + ) + run!(writer_runtime_scene) + @test only(model_objects(writer_runtime_scene; scale=:Leaf)).status.biomass == 0.0 + + lifecycle_scene = CompositeModel( + Object( + :scene; + scale=:Scene, + kind=:scene, + status=Status(created_count=0, removed_count=0), + ), + Object( + :plant_1; + scale=:Plant, + kind=:plant, + parent=:scene, + status=Status(signals=[0.0], signal_total=0.0), + ), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:plant_1, status=Status(signal=0.0)), + Object(:leaf_2; scale=:Leaf, kind=:plant, parent=:plant_1, status=Status(signal=0.0)); + applications=( + ModelSpec(ModelObjectGrowthModel(); name=:growth) |> + AppliesTo(One(scale=:Scene)), + ModelSpec(ModelObjectSignalSourceModel(); name=:leaf_signal) |> + AppliesTo(Many(scale=:Leaf)), + ModelSpec(ModelObjectPlantSignalSumModel(); name=:plant_signal_total) |> + AppliesTo(One(scale=:Plant)) |> + Inputs(:signals => Many(scale=:Leaf, within=Subtree(), var=:signal)), + ModelSpec(ModelObjectPruningModel(); name=:pruning) |> + AppliesTo(One(scale=:Scene)), + ), + environment=(duration=Hour(1),), + ) + lifecycle_output_request = OutputRequest( + :Leaf, + :signal; + name=:leaf_signal_hourly, + process=:model_object_signal_source, + policy=HoldLast(), + clock=Hour(1), + ) + lifecycle_simulation = run!( + lifecycle_scene; + steps=3, + outputs=lifecycle_output_request, + ) + @test !Advanced.bindings_dirty(lifecycle_scene) + @test !Advanced.environment_bindings_dirty(lifecycle_scene) + @test lifecycle_simulation.compiled.revision == Advanced.model_revision(lifecycle_scene) + @test Set(object_ids(lifecycle_scene; scale=:Leaf)) == + Set([ObjectId(:leaf_1), ObjectId(:grown_leaf)]) + lifecycle_status = only(model_objects(lifecycle_scene; scale=:Scene)).status + @test lifecycle_status.created_count == 1 + @test lifecycle_status.removed_count == 1 + lifecycle_leaf_statuses = Dict(object.id.value => object.status for object in model_objects(lifecycle_scene; scale=:Leaf)) + @test lifecycle_leaf_statuses[:leaf_1].signal == 3.0 + @test lifecycle_leaf_statuses[:grown_leaf].signal == 2.0 + @test only(model_objects(lifecycle_scene; scale=:Plant)).status.signal_total == 5.0 + lifecycle_application = lifecycle_simulation.compiled.applications_by_id[:leaf_signal] + @test lifecycle_application.target_ids == [ObjectId(:grown_leaf), ObjectId(:leaf_1)] + lifecycle_execution_row = only( + row for row in explain_execution_plan(lifecycle_simulation) + if row.application_id == :leaf_signal + ) + @test lifecycle_execution_row.object_ids == [:grown_leaf, :leaf_1] + @test lifecycle_simulation.execution_plan.model_revision == + Advanced.model_revision(lifecycle_scene) + @test haskey( + lifecycle_simulation.compiled.model_bundles_by_target, + (:leaf_signal, ObjectId(:grown_leaf)), + ) + lifecycle_binding = only( + row for row in explain_bindings(lifecycle_simulation.compiled) + if row.application_id == :plant_signal_total + ) + @test lifecycle_binding.source_ids == [:grown_leaf, :leaf_1] + @test all( + first(key) == :leaf_signal && last(key) == :signal + for key in keys(outputs(lifecycle_simulation)) + ) + @test only(explain_output_retention(lifecycle_simulation)) == ( + application_id=:leaf_signal, + variable=:signal, + reasons=(:output_request,), + retention_steps=nothing, + current_target_count=2, + ) + @test length(collect_outputs(lifecycle_simulation, :leaf_1, :signal; sink=nothing)) == 3 + @test length(collect_outputs(lifecycle_simulation, :grown_leaf, :signal; sink=nothing)) == 2 + @test length(collect_outputs(lifecycle_simulation, :leaf_2, :signal; sink=nothing)) == 2 + lifecycle_requested_rows = collect_outputs( + lifecycle_simulation, + :leaf_signal_hourly; + sink=nothing, + ) + @test count(row -> row.object_id == :leaf_1, lifecycle_requested_rows) == 3 + @test count(row -> row.object_id == :grown_leaf, lifecycle_requested_rows) == 2 + @test count(row -> row.object_id == :leaf_2, lifecycle_requested_rows) == 2 + + moving_environment_backend = ModelObjectMutableEnvironmentBackend( + :cell_a => 20.0, + :cell_b => 30.0, + ) + moving_environment_scene = CompositeModel( + Object( + :scene; + scale=:Scene, + kind=:scene, + geometry=(cell=:cell_a,), + status=Status(move_count=0), + ), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + geometry=(cell=:cell_a,), + status=Status(temperature_seen=0.0), + ); + applications=( + ModelSpec(ModelObjectGeometryMoverModel(); name=:geometry_mover) |> + AppliesTo(One(scale=:Scene)), + ModelSpec(ModelObjectEnvironmentProbeModel(); name=:moving_probe) |> + AppliesTo(One(scale=:Leaf)) |> + Environment(provider=:grid), + ), + environment=moving_environment_backend, + ) + moving_environment_simulation = run!(moving_environment_scene; steps=2, outputs=:all) + @test !Advanced.bindings_dirty(moving_environment_scene) + @test !Advanced.environment_bindings_dirty(moving_environment_scene) + @test only(model_objects(moving_environment_scene; scale=:Scene)).status.move_count == 1 + @test only(model_objects(moving_environment_scene; scale=:Leaf)).status.temperature_seen == 30.0 + moving_probe_rows = collect_outputs( + moving_environment_simulation, + :leaf_1, + :temperature_seen; + sink=nothing, + ) + @test getproperty.(moving_probe_rows, :value) == [20.0, 30.0] + @test only( + row for row in explain_environment_bindings(moving_environment_simulation.environment_bindings) + if row.application_id == :moving_probe + ).cell == :cell_b + + multirate_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0)); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:hourly_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(2)), + ), + environment=(duration=Hour(1),), + ) + multirate_compiled = Advanced.refresh_bindings!(multirate_scene) + schedule_rows = explain_schedule(multirate_compiled) + @test only(schedule_rows).application_id == :hourly_signal + @test only(schedule_rows).dt_steps == 2.0 + @test only(schedule_rows).dt_seconds == 7200.0 + run!(multirate_scene; steps=5) + @test only(model_objects(multirate_scene; scale=:Leaf)).status.signal == 3.0 + + trait_clock_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0)); + applications=( + ModelSpec(ModelObjectTraitClockSourceModel(); name=:trait_clock_signal) |> + AppliesTo(One(scale=:Leaf)), + ), + environment=(duration=Hour(1),), + ) + trait_clock_compiled = Advanced.refresh_bindings!(trait_clock_scene) + trait_clock_schedule = only(explain_schedule(trait_clock_compiled)) + @test trait_clock_schedule.application_id == :trait_clock_signal + @test trait_clock_schedule.dt_steps == 2.0 + @test trait_clock_schedule.phase == 1.0 + run!(trait_clock_scene; steps=5) + @test only(model_objects(trait_clock_scene; scale=:Leaf)).status.signal == 3.0 + + trait_clock_override_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0)); + applications=( + ModelSpec(ModelObjectTraitClockSourceModel(); name=:override_signal) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(1)), + ), + environment=(duration=Hour(1),), + ) + override_schedule = only(explain_schedule(Advanced.refresh_bindings!(trait_clock_override_scene))) + @test override_schedule.dt_steps == 1.0 + run!(trait_clock_override_scene; steps=5) + @test only(model_objects(trait_clock_override_scene; scale=:Leaf)).status.signal == 5.0 + + strict_hint_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0)); + applications=( + ModelSpec(ModelObjectStrictHintSourceModel(); name=:strict_hint_signal) |> + AppliesTo(One(scale=:Leaf)), + ), + environment=(duration=Hour(1),), + ) + @test_throws "outside `timestep_hint.required=1 day`" Advanced.refresh_bindings!(strict_hint_scene) + + strict_hint_override_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene, status=Status(signal=0.0)); + applications=( + ModelSpec(ModelObjectStrictHintSourceModel(); name=:strict_hint_override) |> + AppliesTo(One(scale=:Leaf)) |> + TimeStep(Hour(1)), + ), + environment=(duration=Hour(1),), + ) + @test only(explain_schedule(Advanced.refresh_bindings!(strict_hint_override_scene))).dt_steps == 1.0 + run!(strict_hint_override_scene; steps=2) + @test only(model_objects(strict_hint_override_scene; scale=:Leaf)).status.signal == 2.0 +end diff --git a/test/test-updates.jl b/test/test-updates.jl new file mode 100644 index 000000000..671742fb1 --- /dev/null +++ b/test/test-updates.jl @@ -0,0 +1,122 @@ +using Dates + +PlantSimEngine.@process "update_carbon_allocation" verbose = false +PlantSimEngine.@process "update_leaf_pruning" verbose = false +PlantSimEngine.@process "update_leaf_senescence" verbose = false +PlantSimEngine.@process "update_biomass_observer" verbose = false + +struct UpdateCarbonAllocationModel <: AbstractUpdate_Carbon_AllocationModel end +PlantSimEngine.inputs_(::UpdateCarbonAllocationModel) = NamedTuple() +PlantSimEngine.outputs_(::UpdateCarbonAllocationModel) = (leaf_biomass=0.0,) +function PlantSimEngine.run!(::UpdateCarbonAllocationModel, models, status, meteo, constants=nothing, extra=nothing) + status.leaf_biomass = 10.0 + return nothing +end + +struct UpdateLeafPruningModel <: AbstractUpdate_Leaf_PruningModel end +PlantSimEngine.inputs_(::UpdateLeafPruningModel) = NamedTuple() +PlantSimEngine.outputs_(::UpdateLeafPruningModel) = (leaf_biomass=0.0,) +function PlantSimEngine.run!(::UpdateLeafPruningModel, models, status, meteo, constants=nothing, extra=nothing) + status.leaf_biomass = 0.0 + return nothing +end + +struct UpdateLeafSenescenceModel <: AbstractUpdate_Leaf_SenescenceModel end +PlantSimEngine.inputs_(::UpdateLeafSenescenceModel) = NamedTuple() +PlantSimEngine.outputs_(::UpdateLeafSenescenceModel) = (leaf_biomass=0.0,) +function PlantSimEngine.run!(::UpdateLeafSenescenceModel, models, status, meteo, constants=nothing, extra=nothing) + status.leaf_biomass *= 0.5 + return nothing +end + +struct UpdateBiomassObserverModel <: AbstractUpdate_Biomass_ObserverModel end +PlantSimEngine.inputs_(::UpdateBiomassObserverModel) = (leaf_biomass=0.0,) +PlantSimEngine.outputs_(::UpdateBiomassObserverModel) = (observed_biomass=0.0,) +function PlantSimEngine.run!(::UpdateBiomassObserverModel, models, status, meteo, constants=nothing, extra=nothing) + status.observed_biomass = status.leaf_biomass + return nothing +end + +function update_scene(applications...) + CompositeModel( + Object( + :leaf; + scale=:Leaf, + status=Status(leaf_biomass=1.0, observed_biomass=-1.0), + ); + applications=applications, + environment=Atmosphere( + T=20.0, + Rh=0.65, + Wind=1.0, + duration=Dates.Hour(1), + ), + ) +end + +@testset "ModelSpec Updates" begin + @test_throws "Ambiguous canonical writers" Advanced.compile_composite_model( + update_scene( + ModelSpec(UpdateCarbonAllocationModel()) |> AppliesTo(One(scale=:Leaf)), + ModelSpec(UpdateLeafPruningModel()) |> AppliesTo(One(scale=:Leaf)), + ), + ) + + model = update_scene( + ModelSpec(UpdateCarbonAllocationModel()) |> AppliesTo(One(scale=:Leaf)), + ModelSpec(UpdateLeafPruningModel()) |> + AppliesTo(One(scale=:Leaf)) |> + Updates(:leaf_biomass; after=:update_carbon_allocation), + ModelSpec(UpdateBiomassObserverModel()) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs( + :leaf_biomass => One( + scale=:Leaf, + application=:update_leaf_pruning, + var=:leaf_biomass, + ), + ), + ) + run!(model) + leaf = only(model_objects(model; scale=:Leaf)) + @test leaf.status.leaf_biomass == 0.0 + @test leaf.status.observed_biomass == 0.0 + + @test_throws "without an ordering relation" Advanced.compile_composite_model( + update_scene( + ModelSpec(UpdateCarbonAllocationModel()) |> AppliesTo(One(scale=:Leaf)), + ModelSpec(UpdateLeafPruningModel()) |> + AppliesTo(One(scale=:Leaf)) |> + Updates(:leaf_biomass; after=:update_carbon_allocation), + ModelSpec(UpdateLeafSenescenceModel()) |> + AppliesTo(One(scale=:Leaf)) |> + Updates(:leaf_biomass; after=:update_carbon_allocation), + ), + ) + + ordered = update_scene( + ModelSpec(UpdateCarbonAllocationModel()) |> AppliesTo(One(scale=:Leaf)), + ModelSpec(UpdateLeafSenescenceModel()) |> + AppliesTo(One(scale=:Leaf)) |> + Updates(:leaf_biomass; after=:update_carbon_allocation), + ModelSpec(UpdateLeafPruningModel()) |> + AppliesTo(One(scale=:Leaf)) |> + Updates( + :leaf_biomass; + after=(:update_carbon_allocation, :update_leaf_senescence), + ), + ModelSpec(UpdateBiomassObserverModel()) |> + AppliesTo(One(scale=:Leaf)) |> + Inputs( + :leaf_biomass => One( + scale=:Leaf, + application=:update_leaf_pruning, + var=:leaf_biomass, + ), + ), + ) + run!(ordered) + ordered_leaf = only(model_objects(ordered; scale=:Leaf)) + @test ordered_leaf.status.leaf_biomass == 0.0 + @test ordered_leaf.status.observed_biomass == 0.0 +end