diff --git a/.github/workflows/Benchmarks.yml b/.github/workflows/Benchmarks.yml index ab6623ff7..bc865232e 100644 --- a/.github/workflows/Benchmarks.yml +++ b/.github/workflows/Benchmarks.yml @@ -3,19 +3,29 @@ on: pull_request_target: branches: [ main ] workflow_dispatch: + inputs: + full_performance: + description: Run the complete XPalm performance workflow + required: false + default: false + type: boolean permissions: pull-requests: write + contents: read jobs: bench: + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.full_performance }} name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} runs-on: ${{ matrix.os }} timeout-minutes: 60 + env: + PSE_BENCHMARK_INCLUDE_DOWNSTREAM: "false" strategy: fail-fast: false matrix: version: - - "1" + - "1.12.1" os: - ubuntu-latest arch: @@ -25,4 +35,10 @@ jobs: with: julia-version: ${{ matrix.version }} bench-on: ${{ github.event.pull_request.head.sha }} - extra-pkgs: https://github.com/PalmStudio/XPalm.jl,https://github.com/VEZY/PlantBiophysics.jl + + full-performance: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.full_performance }} + uses: ./.github/workflows/FullPerformance.yml + with: + xpalm_ref: codex/xpalm-release-regression + plantbiophysics_ref: multi-plant 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/.github/workflows/FullPerformance.yml b/.github/workflows/FullPerformance.yml new file mode 100644 index 000000000..87337cb5c --- /dev/null +++ b/.github/workflows/FullPerformance.yml @@ -0,0 +1,111 @@ +name: Full XPalm performance + +on: + schedule: + - cron: "17 2 * * *" + push: + tags: + - "v*" + workflow_dispatch: + inputs: + xpalm_ref: + description: XPalm branch, tag, or commit + required: true + default: main + type: string + plantbiophysics_ref: + description: PlantBiophysics branch, tag, or commit + required: true + default: master + type: string + workflow_call: + inputs: + xpalm_ref: + description: XPalm branch, tag, or commit + required: false + default: main + type: string + plantbiophysics_ref: + description: PlantBiophysics branch, tag, or commit + required: false + default: master + type: string + +permissions: + contents: read + +concurrency: + group: full-xpalm-performance-${{ github.ref }} + cancel-in-progress: false + +jobs: + full-xpalm-performance: + name: XPalm full cycle + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + JULIA_NUM_THREADS: "1" + JULIA_PKG_PRECOMPILE_AUTO: "0" + PSE_BENCHMARK_INCLUDE_DOWNSTREAM: "true" + steps: + - name: Check out PlantSimEngine + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Check out XPalm + uses: actions/checkout@v6 + with: + repository: PalmStudio/XPalm.jl + ref: ${{ inputs.xpalm_ref || 'main' }} + path: downstream/XPalm + + - name: Check out PlantBiophysics + uses: actions/checkout@v6 + with: + repository: VEZY/PlantBiophysics.jl + ref: ${{ inputs.plantbiophysics_ref || 'master' }} + path: downstream/PlantBiophysics + + - name: Set up Julia + uses: julia-actions/setup-julia@v3 + with: + version: "1.12.1" + + - name: Cache Julia packages + uses: julia-actions/cache@v3 + + - name: Resolve and instantiate the benchmark environment + shell: julia --project=benchmark --color=yes {0} + run: | + using Pkg + + benchmark_project = joinpath(pwd(), "benchmark", "Project.toml") + include(joinpath(pwd(), "benchmark", "prepare_full_performance_project.jl")) + prepare_full_performance_project!(benchmark_project) + Pkg.activate(joinpath(pwd(), "benchmark")) + Pkg.develop([ + PackageSpec(path=pwd()), + PackageSpec(path=joinpath(pwd(), "downstream", "XPalm")), + PackageSpec(path=joinpath(pwd(), "downstream", "PlantBiophysics")), + ]) + Pkg.resolve() + Pkg.instantiate() + Pkg.precompile() + + - name: Run the complete XPalm performance and correctness matrix + run: >- + julia --project=benchmark --color=yes + benchmark/test/runtests.jl + "XPalm staged performance profile full" + + - name: Persist measurements and the resolved environment + if: always() + uses: actions/upload-artifact@v4 + with: + name: xpalm-full-performance-${{ github.run_id }}-${{ github.run_attempt }} + path: | + benchmark/results/xpalm-full-latest.csv + benchmark/Manifest.toml + if-no-files-found: warn + retention-days: 90 diff --git a/.gitignore b/.gitignore index f81ea322b..9228d1f28 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ docs/Manifest.toml test/Manifest.toml docs/build/ benchmark/Manifest.toml -frontend \ No newline at end of file +benchmark/results/ +/frontend/node_modules/ +/frontend/.vite/ +/frontend/test-results/ +/frontend/playwright-report/ diff --git a/AGENTS.md b/AGENTS.md index 119f35fd3..a660dda6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,340 +1,224 @@ # 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. +- `environment_inputs_(model)` and `environment_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) +PlantSimEngine.run!(model, status, environment, constants, context) ``` -- `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 - -- `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. - -## Dependency Graph Under The Hood +Read the current model's parameters directly from `model`. `context` is a +`RunContext` and provides `run_call!`, `call_targets`, and lifecycle access. -### Hard dependencies +## CompositeModel Structure -- 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. +- `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. -Important nuance: +## Model Applications -- 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. +Use one configuration grammar: -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 - -### Mapping modes - -PlantSimEngine distinguishes three mapping modes: - -- `SingleNodeMapping(scale)`: one scalar value is read from one source scale. -- `MultiNodeMapping(scales)`: one input reads a vector of values from many source nodes. -- `SelfNodeMapping()`: a source scale must expose a scalar reference to itself so other scales can share it. - -The runtime carrier is `MappedVar`, which stores: - -- the mapping mode -- the local variable name -- the source variable name -- the resolved default value +```julia +ModelSpec(model; name=:application, on=selector, inputs=(...), calls=(...), every=Dates.Hour(1), environment=Environment(...)) +``` -### Supported mapping forms +- `on` selects where the model runs. +- `inputs` declares value dependencies. +- `calls` declares manually executable hard dependencies. +- `Updates(:x; after=:producer)` orders intentional duplicate writers. +- `output_routing=(x=:stream_only,)` excludes an output from canonical + ownership while retaining its stream. -These are the important user-level forms and what they become internally: +## Selectors -| 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 | +Multiplicity: + +- `One(...)` +- `OptionalOne(...)` +- `Many(...)` + +Scope and topology: -### Mapping compilation pipeline +- `SceneScope()` +- `Self()`: the current object +- `Subtree()`: the current object and its descendants +- `SelfPlant()`: the current plant instance/root +- `Ancestor(...)` +- `Scope(name)` +- `Relation(...)` -`mapped_variables(...)` does not just mirror user syntax. It compiles it. +Use keyword criteria for object labels: `kind=:plant`, `species=:oil_palm`, +`scale=:Leaf`, and `name=:leaf_1`. -The main passes are: +`Self()` never means the model, species, or plant unless the current object is +itself that plant. -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` +## Value Coupling -### Reverse mapping and status wiring +The compiler resolves `ModelSpec(...; inputs=...)` to reference carriers: -- 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. +- one source uses a shared `Ref`; +- many homogeneous sources use `RefVector`; +- heterogeneous sources use `ObjectRefVector`; +- temporal policies read typed output streams. -During `init_node_status!`: +Use `Diagnostics.input_carrier`, `Diagnostics.input_value`, `Diagnostics.explain_bindings`, and +`Diagnostics.has_reference_carrier` instead of inspecting internal fields. -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`. +Same-object input/output matches are inferred when unique. Cross-object +coupling should be explicit with `ModelSpec(...; inputs=...)`. -### Copies vs references +## Hard Calls -- 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. +Hard dependencies are parent-controlled: -## Multi-Rate Runtime - -Multi-rate behavior is layered on top of the multiscale MTG runtime. +1. Declare them with model-level `Call(...)` or scenario-level `ModelSpec(...; calls=...)`. +2. Execute all resolved targets with `run_call!(context, name)`. It always + returns a vector-like `CallTargets` collection. +3. For selective or iterative execution, inspect `call_targets(context, name)` + and execute individual `CallTarget`s with `run_call!`. -### Timing and policies +`run_call!` defaults to `publish=false`, which is appropriate for iterative +trial states. Publish only the accepted state with `publish=true`. -- `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()`. +Applications used exclusively as call targets are not run by the root +scheduler and do not receive inferred soft bindings. -### ModelSpec configuration surface +## Time -`ModelSpec` is the configuration point for scenario-specific runtime behavior. +- `ModelSpec(...; every=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. -It can define: +Dates periods are converted using meteorology `duration`. Model code should not +know its scenario timestep unless the scientific model explicitly requires it. -- `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 +## Environment -### Input binding inference +- `Environment(...)` configures provider selection and source remapping. +- Global meteorology and spatial backends use the same model-facing contract. +- Spatial object-to-environment handles are compiled and cached. +- `move_object!`, `update_geometry!`, or + `mark_environment_binding_dirty!` invalidate affected bindings. +- `run_call!(context, name; environment=trial_state)` samples transient + backend-specific state through each target's compiled handle. +- `environment_outputs_` declares environment variables a controller may commit, and + `commit_environment!` commits only the accepted state. -- 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: +## Lifecycle -- 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). +- `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 after the application that made the + change; new objects can run applications that remain later in the timestep. +- Geometry changes refresh only affected environment bindings when possible. +- Removed objects keep their historical output samples. + +## Outputs -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. +- `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. +- `final_state(simulation)` returns the latest one-object status snapshot; + pass an object id or selector for multi-object simulations. +- `outputs(sim)` exposes retained typed streams. +- `OutputRequest` selects retained/resampled outputs. +- `collect_outputs(sim)` materializes output rows. +- `Diagnostics.explain_output_retention(sim)` reports why each stream is retained. + +Streams are keyed by application, object, and variable so repeated processes do +not overwrite each other. + +## Performance Rules + +- 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 hard-call targets 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/environment_sampling.jl`: model-facing environment 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..b343d94cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## v0.15.0 + +### Breaking changes + +- Environment backends now compile an opaque per-target handle with + `bind_environment`. Accepted state is committed explicitly with + `commit_environment!`, while provider-aware trial state is passed with + `run_call!(context, name; environment=state)`. The former support/scatter, + scoped override, and context-level meteorology APIs were removed. +- 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 +48,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. @@ -36,7 +63,7 @@ to migrate mapping code to the newer API. `InputBindings`, `MeteoBindings`, `MeteoWindow`, `OutputRouting`, and `ScopeModel`. - New model traits for multi-rate inference and defaults: - `output_policy`, `timestep_hint`, and `meteo_hint`. + `output_policy`, `timestep_hint`, and `environment_hint`. - New export API for resampled output streams with `OutputRequest(...)` and `collect_outputs(...)`. - New debugging/introspection helpers: @@ -77,12 +104,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 +120,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 +140,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 +160,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 +207,7 @@ If your mappings still use string scales, migrate them to symbols. Before: ```julia -mapping = ModelMapping( +mapping = PlantSimEngine.ModelMapping( "Leaf" => (ToyAssimModel(),), ) @@ -191,7 +218,7 @@ MultiScaleModel([:A => "Leaf"]) After: ```julia -mapping = ModelMapping( +mapping = PlantSimEngine.ModelMapping( :Leaf => (ToyAssimModel(),), ) @@ -230,7 +257,7 @@ For reusable models, it is now often worth defining: - `output_policy(::Type{<:MyModel})` to describe the natural aggregation rule for a produced variable, - `timestep_hint(::Type{<:MyModel})` to declare valid/preferred cadences, -- `meteo_hint(::Type{<:MyModel})` to declare default weather aggregation rules. +- `environment_hint(::Type{<:MyModel})` to declare default environment aggregation rules. This is optional, but it makes inference more useful and error messages more actionable. 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..288e5c037 100644 --- a/README.md +++ b/README.md @@ -9,369 +9,228 @@ [![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_` declarations using `Required(T)` or `Default(value)` +- `outputs_` +- optional `dep`, `timespec`, `output_policy`, `environment_inputs_`, and + `environment_outputs_` traits +- `run!(model, status, environment, constants, context)` -**Why choose PlantSimEngine?** +A simulation author assembles those kernels on objects with `CompositeModel`, +`Object`, and one direct application constructor: -- **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 +ModelSpec( + model; + name=:application, + on=Many(scale=:Leaf), + inputs=(...), + calls=(...), + every=Hour(1), + environment=Environment(...), + output_routing=(...), + updates=Updates(...), +) +``` -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 +## Quickstart -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 - -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 the `inputs` keyword when a model needs values from selected objects. This +model-scale LAI application 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, + on=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) +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. -### Multiscale modelling +## Manual Calls -> See the Multi-scale modeling section of the docs for more details. - -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 the `calls` keyword 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] - ], - ), - 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], +ModelSpec( + SceneEnergyBalance(); + name=:scene_energy, + on=One(scale=:Scene), + calls=( + :leaf_energy => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:energy_balance, ), - 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(), ), -); -``` - -We can import an example plant from the package: - -```julia -mtg = import_mtg_example() + every=Hour(1), +) ``` -Make a fake meteorological data: - -```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) -] -); -``` +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 application +names chosen by future scenarios. -And run the simulation: +Inside the parent model, `run_call!(context, :leaf_energy)` executes every target +and returns a vector-like collection. For iterative control, +`call_targets(context, :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. -```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,), -) +## What PlantSimEngine Handles -out = run!(mtg, mapping, meteo, outputs=out_vars, executor=SequentialEx()); -``` +- 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 `environment_outputs_`; +- growth, pruning, reparenting, and movement with binding-cache refresh; +- structured explanations for users and agents. -We can then extract the outputs in a `DataFrame` and sort them: +Useful inspection helpers include: ```julia -using DataFrames -df_out = convert_outputs(out, DataFrame) -sort!(df_out, [:timestep, :node]) +explain_objects(model) +explain_scopes(model) +explain_bindings(model) +explain_calls(model) +explain_environment_bindings(model) +explain_schedule(model) +explain_execution_plan(model) ``` -| **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 | - -An example output of a multiscale simulation is shown in the documentation of PlantBiophysics.jl: - -![Plant growth simulation](docs/src/www/image.png) +## Documentation -### Multi-rate modelling +- [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/) -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. +## Projects That Use PlantSimEngine -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/TEMP_API_DOCUMENTATION_GOAL.md b/TEMP_API_DOCUMENTATION_GOAL.md new file mode 100644 index 000000000..37f39e57a --- /dev/null +++ b/TEMP_API_DOCUMENTATION_GOAL.md @@ -0,0 +1,1411 @@ +# Temporary Goal: Finalize the PlantSimEngine API and Progressive Documentation + +> This is a temporary implementation goal, not user documentation. +> +> Use it as the working contract for the next PlantSimEngine API and +> documentation pass. Delete it after every completion criterion has been +> verified and the durable decisions have been moved into the normal +> documentation, changelog, tests, and downstream packages. + +## Objective + +Make PlantSimEngine answer one problem clearly: + +> A model developer implements a scientific process once. A simulation user +> composes such models over objects, scales, plants, timesteps, and +> environments without rewriting their kernels. + +The final API must be as small and unsurprising as possible for: + +1. **model developers**, who implement reusable process models; and +2. **simulation users**, who select models, couple them, construct one or many + plants, and run simulations. + +The documentation must teach this progressively. It must begin with a few +existing models coupled on the same object and run over several timesteps. +Each later journey introduces one substantial concept at a time. + +## Status Legend + +- `[x]` is an established baseline already implemented on the current branch. +- `[ ]` is required work or verification before this goal is complete. + +## Non-Negotiable Constraints + +- [x] Preserve the sole `CompositeModel`/`Object` compiler and runtime. Do not + recreate the removed ModelMapping runtime. +- [x] Prefer clean breaking changes. Do not add deprecated aliases, + compatibility wrappers, old keyword support, or dual APIs unless the + user explicitly requests them. +- [x] Preserve generic numeric and status types, reference carriers, compiled + execution batches, deterministic diagnostics, and allocation-sensitive + paths. +- [x] Treat runtime performance as part of the public contract. The richer + `CompositeModel` semantics must compile into simple hot loops; selector + resolution, dependency compilation, status-view construction, and + execution-plan construction must not become ordinary per-step work. +- [x] Make structural mutation proportional to the changed objects and affected + applications, not to the total number of objects and execution targets + already present in the simulation. +- [x] Keep the common path short. Advanced features must not add arguments, + types, or terminology to the first-time workflow. +- [x] Reuse the models in `PlantSimEngine.Examples` whenever they can teach the + intended concept. Do not redefine a tutorial-only model when an existing + model can be brought up to the current contract. +- [x] Treat PlantBiophysics and XPalm as required downstream acceptance suites, + not optional follow-up cleanup. +- [x] Preserve unrelated and concurrent working-tree changes. Inspect + `git status` in all three repositories before editing. +- [x] Use Kaimon for every Julia process and Julia test run. + +## Current Branch Baseline That Must Not Regress + +### Composite runtime + +- [x] One `CompositeModel` owns an `ObjectRegistry`, applications, instances, + overrides, and an environment. +- [x] Objects have stable identities, labels, parents, geometry, scale, kind, + and `Status`. +- [x] Templates and instances reuse the same logical model definitions across + multiple plants. +- [x] Same-object value coupling can be inferred when unique. +- [x] Cross-object coupling uses explicit `One`, `OptionalOne`, or `Many` + selectors and scope. +- [x] Hard dependencies are parent controlled through `calls`, + `call_targets`, and `run_call!`. +- [x] Runtime mutation refreshes affected selectors, carriers, schedules, and + environment bindings between timesteps. +- [x] Output streams are keyed by application, object, and variable. + +### Environment API implemented by “Review multi-plants changes” + +The following is the current environment semantic baseline. Preserve it even +if the public vocabulary is renamed later in this goal. + +- [x] `EnvironmentContext(application, object_id, scale, process)` contains + immutable target metadata and is used while compiling a backend binding. + It does not expose runtime status or geometry to sampling. +- [x] `bind_environment(backend, object, context, config)` returns an opaque + backend handle. +- [x] Committed sampling uses: + + ```julia + sample(backend, handle, variable, time) + ``` + +- [x] Transient sampling uses: + + ```julia + sample(backend, handle, state, variable, time) + ``` + +- [x] A provider-aware trial state is propagated to every resolved target, + including `Many`, with: + + ```julia + run_call!(context, name; environment=trial_state, publish=false) + ``` + + Each target still samples through its own compiled handle. A multi-voxel + trial therefore does not require a model-level loop over leaves. + +- [x] Accepted mutable state is explicit: + + ```julia + commit_environment!(context, accepted_state) + ``` + +- [x] Environment outputs are declarations of what a model is allowed to + commit. They are not inserted into ordinary object `Status`. +- [x] Fine-grained execution can supply an already sampled row to one selected + `CallTarget`. This is an advanced escape hatch, separate from the + provider-aware `Many` path. +- [x] `with_environment!`, `update_environment!`, `EnvironmentSupport`, the + override stack, context-level `meteo=`, automatic scatter, and + status-based environment routing have been removed. +- [x] The MAESPA example uses explicit forcing and canopy routes and validates + trial versus accepted environment states. + +The other task reported these Kaimon results for this baseline: + +- MAESPA focused suite: 172 passed; +- unified model/object API: 592 passed; +- complete PlantSimEngine suite: 1,324 passed; +- `git diff --check`: passed. + +These results must be rerun after the API and documentation work below. Do not +present them as validation of later edits. + +### Working-tree snapshot at goal start + +- [x] PlantSimEngine is on `codex/pse-downstream-regression` at `7e38354f`. + The only initial untracked path is this goal document, created for the + current work. +- [x] XPalm is on `codex/xpalm-release-regression` at `0018162`. Its + pre-existing `CHANGELOG.md` modification is concurrent user work and must + not be overwritten or included in unrelated commits. +- [x] PlantBiophysics is on `codex/plantbiophysics-downstream-regression` at + `1085356`. Its pre-existing logo source, SVG removal, PNG addition, and + documentation-assets test edits are concurrent user work and must not be + overwritten or included in unrelated commits. +- [x] Recheck all three worktrees before every cross-repository milestone; this + snapshot records starting ownership but does not make later changes safe + to assume. + +## Target Public API + +### 1. Model-developer kernel + +The canonical kernel now carries only the current model, target status, +sampled environment, constants, and execution context. The former +process-keyed `models` bundle and `extra` vocabulary are no longer part of the +PlantSimEngine model-developer API. + +- [x] Replace the canonical model kernel with: + + ```julia + PlantSimEngine.run!( + model, + status, + environment, + constants, + context, + ) + ``` + +- [x] Use `model.parameter` for the current model's parameters. +- [x] Remove the `models` bundle from the model-developer contract. +- [x] Expose hard-call models and targets only through focused context + functions such as `call_targets`, `run_call!`, and a small read-only model + accessor if one is genuinely needed. +- [x] Use `context`, not `extra`, throughout source, examples, documentation, + errors, and downstream packages. +- [x] Keep the model kernel responsible for one timestep and one target. + PlantSimEngine owns the timeline, scheduling, target iteration, and + publication. +- [x] Migrate all PlantSimEngine examples and tests. +- [x] Migrate all PlantBiophysics and XPalm model kernels. +- [x] Do not retain the six-argument method as compatibility. + +### 2. One environment vocabulary + +The model-facing vocabulary now consistently uses `environment`. PlantMeteo +remains the meteorological data provider/adapter and is not the name of the +general PlantSimEngine environment abstraction. + +- [x] Rename the model traits consistently: + + ```julia + environment_inputs_(model) + environment_outputs_(model) + ``` + +- [x] Rename related helpers, explanations, graph fields, diagnostics, and + error messages consistently. +- [x] Call the sampled model-facing kernel argument `environment`. +- [x] Keep `environment=` on context-level `run_call!` for a typed transient + backend state. +- [x] Rename the per-target bypass keyword to an unambiguous name such as + `sampled_environment=`; it supplies an already sampled row and bypasses + provider sampling for exactly one target. +- [x] Keep PlantMeteo as a provider/adapter, not as the name of PlantSimEngine's + general environment abstraction. +- [x] Do not reintroduce the removed override-stack or scatter APIs. + +Milestone validation on the PlantSimEngine source state: + +- environment trait tests: 5 pass; +- environment sampling tests: 15 pass; +- environment backend tests: 9 pass; +- hard-call tests: 49 pass; +- model-contract tests: 15 pass; +- configuration-error tests: 6 pass; +- API-stabilization tests: 148 pass; +- graph-view tests: 115 pass; +- graph-editor extension tests: 74 pass; +- MAESPA tests: 170 pass; +- unified model/object API tests: 614 pass; +- isolated Aqua ambiguity check: 1 pass after resolving the retained-stream + overload ambiguity; +- full PlantSimEngine suite, including Aqua and doctests: 1,461 pass. + +### 3. Required inputs versus true defaults + +Input literals are currently described as defaults even when an unresolved +input still causes compilation to fail. + +- [x] Make required and defaulted inputs explicit. The target contract is: + + ```julia + inputs_(::MyModel) = ( + required_value=Required(Float64), + optional_value=Default(1.0), + ) + ``` + +- [x] Keep output literals as initial output-state values unless a more explicit + output schema is justified. +- [x] Ensure required/default declarations preserve generic types and do not + force `Float64`. +- [x] Remove undocumented `-Inf`-as-required conventions from canonical + examples. +- [x] Make initialization explanations report whether a value is required, + defaulted, user supplied, or bound from another application. +- [x] Add compile-error tests for missing required inputs and tests proving that + true defaults require no user initialization. + +Milestone validation: + +- PlantSimEngine commits `757649eb`, `143f07df`, and `03e0a5f9` implement, + document, and restore the hot-path performance of the explicit schema; +- valid schema lookup is allocation-free, mutable defaults are private per + target, and `Required(Real)` preserves a supplied `BigFloat`; +- status-initialization tests: 28 pass; +- unified model/object API tests: 614 pass; +- the complete Documenter build, including runnable Markdown examples: pass; +- PlantBiophysics commit `0d110a9`: 268 pass; +- XPalm commit `871daaf`: 307 package checks and 68 v0.6.1 full-cycle + regression checks pass; +- no reference fixture was changed. + +### 4. Scenario configuration + +`ModelSpec` currently has both a direct constructor and a pipe DSL. There +should be one normal spelling. + +- [x] Choose one canonical `ModelSpec` construction form and remove the other. +- [x] Prefer a concise direct keyword form along these lines: + + ```julia + ModelSpec( + model; + name=:application, + on=Many(scale=:Leaf, within=SelfPlant()), + inputs=(...), + calls=(...), + every=Hour(1), + environment=Environment(...), + ) + ``` + +- [x] Preserve `Updates` and output-routing semantics, but place them in the + same canonical configuration form. +- [x] Remove the pipe helpers if direct keywords are selected; do not document + both as equal alternatives. +- [x] Migrate XPalm's scenario compiler as a primary design test. + +Milestone validation: + +- PlantSimEngine commit `e00febda` makes + `ModelSpec(model; name, on, inputs, calls, every, environment, + output_routing, updates)` the only scenario-construction form; +- `AppliesTo`, `Inputs`, `Calls`, `TimeStep`, `OutputRouting`, their callable + behavior, and the public `ModelSpec(spec; ...)` copy constructor are removed; +- namespace-boundary tests verify that every removed builder is undefined; +- unified model/object API tests: 614 pass; +- API stabilization tests: 166 pass; +- graph-view tests: 117 pass; graph-editor source round-trip tests: 74 pass; +- MAESPA example tests: 170 pass; every other changed focused test file passes; +- the complete Documenter build, including runnable Markdown examples: pass; +- PlantBiophysics commit `b79dc18`: 268 pass; +- XPalm commit `356ab43`: 307 package checks and 68 v0.6.1 full-cycle + regression checks pass; +- the warmed XPalm full cycle is 9.840 seconds after the migration, compared + with 9.714 seconds before it and 7.501 seconds for the historical + XPalm v0.6.1 / PlantSimEngine v0.14.1 baseline; +- no reference fixture was changed, and XPalm's unrelated `CHANGELOG.md` + modification remains untouched. + +### 5. Selectors + +- [x] Keep `One`, `OptionalOne`, and `Many`. +- [x] Keep meaningful topology scopes: `Self`, `Subtree`, `SelfPlant`, + `SceneScope`, `Ancestor`, `Scope`, and `Relation`. +- [x] Prefer keyword criteria such as `scale=:Leaf` over duplicate wrapper + spellings such as `Scale(:Leaf)`. +- [x] Validate selector fields by context. For example, an application-target + selector must not silently accept value-routing-only fields. +- [x] Document `Self()` literally as the current object. It never means the + current plant unless that object is the plant. +- [x] Ensure structured selector diagnostics include every semantically + relevant normalized field. + +Validation evidence: + +- PlantSimEngine commit `75a00b88` keeps the multiplicity and topology + vocabulary, removes the duplicate `Kind`, `Species`, and `Scale` wrapper + types, and makes label criteria keyword-only; +- selector construction rejects unknown fields, while `ModelSpec` validates + distinct field sets for application targets, input bindings, and call + bindings; object queries and `OutputRequest` selectors accept object + criteria only; +- application targets reject relations and object-relative scopes because + they have no current object; `Self()` is documented in source and user docs + as exactly the current object; +- `ObjectAddress` and graph selector DTOs preserve scope, labels, process, + application, variable, relation, temporal policy/window, live-status + routing/order, and multiplicity; +- all 26 focused PlantSimEngine test files pass individually (1,517 checks), + and the focused Aqua quality/ambiguity audit passes (11 checks); +- the complete Documenter build, including runnable Markdown examples: pass; +- XPalm: 307 package checks and 68 XPalm v0.6.1 full-cycle numerical + regression checks pass; +- PlantBiophysics: 268 checks pass; +- no downstream source migration or reference-fixture change was needed, and + XPalm's unrelated `CHANGELOG.md` modification remains untouched. + +### 6. Public namespace + +- [x] Reduce the default exported API to the ordinary model-developer and + simulation-user workflow. +- [x] Move structured `explain_*` and carrier inspection tools under + `PlantSimEngine.Diagnostics`. +- [x] Move graph DTOs, graph edits, and editor sessions under + `PlantSimEngine.GraphEditor`. +- [x] Move backend extension protocol symbols under + `PlantSimEngine.EnvironmentAPI`. +- [x] Move fitting metrics and generic evaluation functions under + `PlantSimEngine.Evaluation`. +- [x] Avoid re-exporting the complete PlantMeteo reducer vocabulary when users + can access it from PlantMeteo directly. +- [x] Generate or test the public symbol inventory so it cannot drift from + actual exports. + +Validation evidence: + +- PlantSimEngine commit `ef464b34` limits the root namespace to the ordinary + modeling and simulation workflow and introduces the focused + `Diagnostics`, `GraphEditor`, `EnvironmentAPI`, and `Evaluation` + namespaces; +- the namespace-boundary test compares the exact root, `Advanced`, + `Diagnostics`, `GraphEditor`, `EnvironmentAPI`, and `Evaluation` public-name + sets with explicit inventories; the focused API stabilization file passes + 239 checks; +- `inputs_`, `outputs_`, `environment_inputs_`, and `environment_outputs_` + remain available as qualified extension functions but are not imported by + `using PlantSimEngine`; +- PlantMeteo reducers are no longer re-exported; `Atmosphere`, `Constants`, + and `Weather` remain the three root conveniences; +- the complete Documenter build, including runnable Markdown examples: pass; +- XPalm commit `4f70803`: 307 package checks and 68 XPalm v0.6.1 full-cycle + numerical regression checks pass; +- PlantBiophysics commit `d252b6b`: 268 checks pass, including the migrated + `Evaluation.fit` extensions, evaluation regressions, and doctests; +- no reference fixture was changed, and XPalm's unrelated `CHANGELOG.md` + modification remains untouched. + +### 7. Simulation results + +- [x] Keep the memory-safe `outputs=:none` default unless measurements justify + changing it. +- [x] Add a concise `show(::Simulation)` that reports elapsed steps, object and + application counts, retained stream count, and an actionable hint when + no streams were retained. +- [x] Add a sanctioned `final_state` or equivalent accessor for common final + values, so tutorials do not require + `only(model_objects(...)).status`. +- [x] Use `outputs=:all` or explicit `OutputRequest`s in tutorials that collect + or plot output streams. +- [x] Introduce basic collection in the first journey; defer retention, + resampling, and routing policies until later. + +Validation evidence: + +- PlantSimEngine commit `e48cc3a9` adds concise compact and plain-text + `Simulation` displays, preserves `outputs=:none`, and adds `final_state` for + one object, an explicit object id, or `One`, `OptionalOne`, and `Many` + selectors; +- `final_state` materializes the latest canonical status independently of + retained history; scalar snapshots remain unchanged when the simulation is + later continued, and `Many` returns an object-id-to-state dictionary; +- commit `a794876d` adds explicit multi-object result coverage; the focused API + stabilization file passes 257 checks; +- every documentation example that calls `collect_outputs` now retains + `outputs=:all` or an explicit `OutputRequest`, while final-value examples use + `final_state`; +- the complete Documenter build, including the first multi-timestep collection + journey and all runnable Markdown examples: pass; +- XPalm: 307 package checks and 68 XPalm v0.6.1 full-cycle numerical + regression checks pass; +- PlantBiophysics: 268 checks pass; +- no reference fixture was changed, and XPalm's unrelated `CHANGELOG.md` + modification remains untouched. + +## Performance Restoration Contract + +Runtime performance is a core package feature and a release-blocking part of +this goal. The new object, selector, lifecycle, hard-call, temporal, and output +semantics must be compiled into efficient execution. Restoring speed must not +remove those semantics or revive the old runtime. + +### Measured regression baseline + +The current full-cycle XPalm regression is directly comparable with the +committed XPalm v0.6.1 fixture: + +- [x] Both measurements use Julia 1.12.1, 4,160 daily steps, the same + meteorology file and SHA-256, `XPalm.default_parameters()`, and the same + end-to-end regression helper. +- [x] XPalm v0.6.1 with PlantSimEngine v0.14.1 completed in + `7.501459958` seconds. +- [x] The current `CompositeModel` runtime completed in `439.521899917` + seconds; two earlier repetitions took approximately 505 and 512 seconds. +- [x] The initial measured regression was therefore `58.59×` slower: + approximately + `105.654 ms` instead of `1.803 ms` per simulated day. +- [x] The reference run finishes with 344 phytomers. +- [x] Reproduce the current timing again from a healthy Kaimon session before + changing runtime code, recording warm-up policy, source revisions, + machine information, wall time, allocations, and output mode. +- [x] Capture sampling and allocation profiles before and during optimization. + The post-ancestor-index CPU profile at `08b71321` attributes + approximately 31% of samples to lifecycle binding refresh, 15% to + current-topology hard-call target materialization, and 13% to temporal + input materialization. Treat these sampling shares as routing evidence, + not additive wall-time guarantees. + +Use XPalm v0.6.1 with PlantSimEngine v0.14.1 as the exact historical performance +reference. A v0.5.1 comparison may be retained as additional historical +evidence, but it must not replace the reproducible v0.6.1 fixture. + +Current restoration checkpoints on the same staged XPalm fixture: + +- [x] At `195a346b`, the 1,000-day run took `4.118958` seconds and allocated + `3.145 GB` with `outputs=:none`; the reference-output run took + `4.089205` seconds and allocated `3.161 GB`. +- [x] At `c1b8cc2f`, the 1,000-day run takes `3.989951` seconds and allocates + `2.702 GB` with `outputs=:none`; the reference-output run takes + `4.285781` seconds and allocates `2.719 GB`. +- [x] Reference-output collection is measured separately at `0.597552` + seconds and `67.4 MB`. +- [x] At `4bf7575e`, the exact 4,160-day `outputs=:none` fixture took + `110.872` seconds and allocated `48.889 GB`. +- [x] The lifecycle hard-call delta committed as `bb619c4e` reduced that + fixture to `52.918` seconds and `29.360 GB`. +- [x] With the persistent dependency graph and delta execution-plan refresh, + the full run took `48.935` seconds and allocated `23.590 GB`. +- [x] Typed global-weather rows, compiler-owned target deltas, cached timeline + reuse, and disabled-path instrumentation removal reduce the fully warmed + 4,160-day run to `14.382` seconds and `9.268 GB`. +- [x] The same checkpoint reduces a fully compiled 660-day mature-canopy tail + from `4.834` seconds and `4.740 GB` to approximately `4.2` seconds and + `2.5 GB`. +- [x] The lifecycle-resume correctness blocker is repaired. When structural + growth activated a previously empty application ahead of already + completed groups, the runtime resumed at the new group but then replayed + completed groups that followed it. This ran `Plant__plant_age` twice at + timesteps 2,164 and 2,209 and shifted the downstream trajectory. The + runtime now skips every completed application while still running newly + activated later applications in the current timestep. +- [x] The dedicated XPalm v0.6.1 full-cycle regression passes all 68 checks. + The corrected warmed run finishes with `current_step=4160`, + `phytomer_count=344`, `lai=5.0587602356164405`, and + `ftsw=0.7991179101191216`, exactly matching the historical final state. +- [x] At committed revision `52ab05c7`, the corrected fully warmed 4,160-day + run takes `14.995239` seconds and allocates `9.267 GB`. Two preceding + repetitions took `14.864538` and `15.007203` seconds with the same exact + final state. +- [x] At committed revision `274666b2`, cached hard-call execution targets + preserve their compiled temporal adapters, nested call collections, and + `RunContext` between lifecycle revisions. The exact warmed 4,160-day run + takes `13.463214` seconds and allocates `7.756 GB`, down from `9.267 GB`; + all 49 focused hard-call checks and all 68 XPalm reference-regression + checks pass. +- [x] In-place subtree enumeration (`57760444`), right-sized lifecycle input + binding construction (`43f08bdb`), and cached ancestor paths in the + object registry (`08b71321`) remove repeated topology traversal and + intermediate-vector growth from lifecycle refresh. +- [x] At committed revision `d60bac4d`, hard-call views synchronize their + timestep, publication, and transient-environment state only when the + declared call is requested. Combined with the lifecycle changes above, + the exact warmed 4,160-day run takes `11.637499` seconds and allocates + `6.994 GB`. The final state remains exactly `current_step=4160`, + `phytomer_count=344`, `lai=5.0587602356164405`, and + `ftsw=0.7991179101191216`; all 49 focused hard-call checks, all 622 + unified runtime checks, and all 68 XPalm reference-regression checks + pass. +- [x] At committed revision `64ef6a6d`, addition-only lifecycle refresh + updates binding and status-view indexes in place, skips scene-wide + environment indexing for global weather, caches dirty-topology call + compilation, and indexes legacy model-bundle propagation by callee + application. The committed exact warmed 4,160-day run takes + `9.845081` seconds and allocates `5.879 GB`. +- [x] The final checkpoint is `44.64×` faster than the initial + `439.521900`-second regression and only `1.31×` slower than the + historical PlantSimEngine v0.14.1 result. It completes at approximately + `2.367 ms` per simulated day, preserves the exact final state, and passes + all 622 unified runtime checks and all 68 XPalm reference-regression + checks. +- [x] After the five-argument kernel and environment-vocabulary migration + (`b7e56655` in PlantSimEngine and `00a8e80` in XPalm), a fresh exact + warmed full-cycle run takes `9.962219` seconds and allocates `6.049 GB`. + It preserves the same exact 4,160-day final state; XPalm passes 307 + package checks and all 68 reference-regression checks. +- [x] At `346e6168`, removal, reparenting, and movement update application + targets, live `Many` carriers, status views, environment indexes, spatial + handles, and execution groups in place. Across scenes with 8 and 8,192 + leaves per plant, the full removal refresh allocates `32.1-33.5 KB` and + the full reparent refresh allocates `44.0-45.8 KB`; the corresponding + minimum refresh times remain below `0.31 ms`. The permanent gate compares + 8 and 2,048 leaves and permits at most `16 KB` additional allocation for + one removal, reparent, or movement delta. +- [x] The first explicit-input implementation accidentally formatted + diagnostics and allocated an invalid-declaration vector on every valid + schema lookup. That raised the warmed XPalm full cycle to + `113.161024` seconds. Commit `03e0a5f9` moves diagnostic construction to + the invalid-only slow path and validates heterogeneous declaration tuples + without allocation. The same warmed full-cycle scenario returns to + `9.714` seconds, while XPalm still passes 307 package checks and all 68 + v0.6.1 regression checks. +- [x] At the final local source checkpoint (`346e6168`), the dedicated exact + warmed no-output gate completes the same 4,160-day scenario in + `8.830646833` seconds, allocates `4.205151272 GB` in `44,848,098` + allocations, and preserves the exact final step, 344 phytomers, LAI, and + FTSW. This is `49.77x` faster than the initial regression and only `1.18x` + slower than PlantSimEngine v0.14.1. The complete persisted profile also + passes its four correctness-first modes; instrumentation adds measurable + overhead and is therefore reported separately from the uninstrumented + acceptance timing. + +### Performance invariants + +- [x] The steady-state scheduler performs one linear traversal of compiled + application groups and targets per timestep. +- [x] A timestep with no structural or environment changes performs no selector + resolution, dependency-graph compilation, status-view construction, + execution-plan construction, or output-retention compilation. +- [x] Adding objects extends or replaces only affected compiled structures. + Unaffected targets retain their status views, temporal storage, reference + carriers, call bindings, environment handles, and execution batches. +- [x] Lifecycle extension cost scales with the number of added, removed, + reparented, or moved objects and the applications affected by that delta. +- [x] Temporal dependency state is distinct from user-retained output history. +- [x] Dynamic dispatch remains at compiled batch boundaries, not inside the + per-object kernel loop. +- [x] Homogeneous steady-state batches preserve the existing zero-allocation + target-loop guarantee. +- [x] Performance improvements preserve deterministic application order, + lifecycle visibility, `PreviousTimeStep` semantics, hard-call + publication, output retention, removed-object history, overrides, and + generic numeric types. + +### 1. Add performance observability before redesign + +- [x] Add opt-in runtime counters and coarse timers for: + - initial composite compilation; + - selector and input/call binding compilation; + - application-order compilation; + - status-view construction; + - model-bundle construction; + - environment-binding compilation and refresh; + - execution-plan construction and extension; + - output-retention compilation; + - temporal input materialization and publication; + - scientific kernel execution; + - output collection. + `run!(...; performance=true)` now records these stages through + `Advanced.runtime_performance(simulation)`. Compilation timers separate + application targeting, input and call bindings, application ordering, + status views, environment bindings, execution plans/model bundles, + retention, runtime materialization/publication, kernels, and collection; + lifecycle counters report both constructed work and replaced or removed + binding targets. +- [x] Count lifecycle refreshes, dirty objects, affected applications, status + views constructed, execution targets constructed, bindings replaced, and + batches rebuilt. +- [x] Keep instrumentation disabled or effectively free in ordinary runs. +- [x] Use `Profile`, allocation measurements, and BenchmarkTools through + Kaimon. Do not infer percentage contributions from static inspection + alone. +- [x] Profile a controlled matrix that separates: + - scene construction from simulation; + - steady-state execution from structural growth; + - `outputs=:none` from requested regression outputs; + - temporal dependencies from user-retained histories; + - simulation from `collect_outputs`; + - a short prefix from the complete 4,160-step cycle. + +### 2. Make lifecycle compilation genuinely incremental + +The current extension path appends new application targets but then rebuilds +all status views. Simulation refresh subsequently recompiles output retention +and the complete execution plan. A hard call restricted to newly created +objects forces this refresh immediately so those objects can be initialized. + +XPalm creates one phytomer, one internode, and one leaf per emission. The three +growing scales are targeted by 27 applications. With 343 emissions after the +initial phytomer, rebuilding every existing view causes at least +`1,602,153` growing-scale status-view constructions, excluding static and +reproductive objects. + +- [x] Preserve `status_views_by_target` for unaffected keys. +- [x] Compile status views only for new targets and for existing targets whose + dynamic input bindings genuinely changed. +- [x] Preserve private temporal storage and reference identity for unaffected + status views. +- [x] Extend `input_bindings_by_target`, `call_bindings_by_target`, and + `model_bundles_by_target` only for affected consumers and callees. +- [x] When a dynamic hard-call selector starts matching a new object, update + only that binding and the callers whose compiled bundle actually changes. + Do not rebuild every call binding or model bundle. +- [x] Cache the application dependency graph or its edge set. Recompute + application order only when a lifecycle delta introduces or removes an + ordering edge. +- [x] Extend the execution plan by appending a new target to a compatible typed + batch or rebuilding only the affected application's groups. +- [x] Update output retention only when a new temporal dependency or requested + output target changes retention. +- [x] Continue using the existing incremental environment-binding refresh for + affected objects; do not rebuild unrelated spatial handles. +- [x] Preserve the application-boundary lifecycle transaction so several + objects created by one kernel are refreshed together. +- [x] Ensure `run_call!(...; objects=new_objects)` can initialize new objects + without triggering a whole-scene rebuild. +- [x] Add lifecycle tests covering creation, removal, reparenting, overrides, + new dynamic `Many` matches, hard-call initialization, and temporal state + preservation. + +### 3. Replace repeated scheduler scanning with application groups + +The current step loop allocates a `Set{Symbol}` and repeatedly calls +`findfirst` from the beginning of the batch vector to locate the next +application. XPalm currently defines 62 applications, and one application can +contain several typed batches. + +- [x] Compile an `ApplicationExecutionGroup` or equivalent representation that + owns one application and its typed batches. +- [x] Execute the ordinary clean-runtime path as one linear loop over groups + and one linear loop over each group's targets. +- [x] Do not allocate a completed-application set on an ordinary timestep. +- [x] Put lifecycle resumption behind a separate slow path. +- [x] After a mid-step refresh, skip already completed application identities + without repeatedly rescanning all earlier batches. +- [x] Preserve correct behavior if a lifecycle change introduces a new + dependency edge and changes the remaining order. +- [x] Benchmark and allocation-test one clean timestep with one batch per + application and with several heterogeneous batches per application. + +### 4. Compile temporal policies into typed state + +XPalm currently declares 17 `PreviousTimeStep` dependencies. Internal temporal +dependencies are published through a global +`Dict{Tuple{Symbol,ObjectId,Symbol},Any}` of sample vectors. Dependency-only +streams then use `deleteat!` or `filter!` during publication. + +- [x] Compile `PreviousTimeStep` to a typed previous/current double buffer or + equivalent fixed-size state. +- [x] Rotate previous-step state at a defined timestep boundary so the result + is independent of same-step producer/consumer execution order. +- [x] Share compiled source cells with all compatible consumers instead of + repeating dictionary lookup by application, object, and variable. +- [x] Implement `Integrate`, `Aggregate`, and other finite-window policies with + typed ring buffers or another bounded representation. +- [x] Keep append-only historical streams only for outputs the user explicitly + retains or requests. +- [x] Preserve initial-value, missing-source, multirate cadence, publication, + continuation, and removed-object history semantics. +- [x] Add allocation and scaling tests for thousands of objects using + `PreviousTimeStep` without retained user output. + +### 5. Restore the no-hard-call fast path per batch + +XPalm has only two `ModelSpec(...; calls=...)` declarations, but the presence of any compiled +call binding currently sends every scheduled target through the call-capable +execution path. + +- [x] Record call capability per compiled target or homogeneous batch. +- [x] Run targets with no call bindings through the no-call kernel even when + unrelated applications in the scene declare hard calls. +- [x] Group call-capable targets by concrete call tuple or another type-stable + representation. +- [x] Avoid a per-target lookup in `call_bindings_by_target` when call bindings + are already known during compilation. +- [x] Cache compiled hard-call execution targets between lifecycle revisions + and invalidate them when compiled bindings, environment bindings, + streams, retention, or constants change. +- [x] Preserve nested calls, selective targets, transient environments, + `publish=false`, accepted publication, and lifecycle-created call targets. +- [x] Benchmark a large scene with zero, sparse, and dense hard-call usage. + +### 6. Separate and preallocate retained outputs + +- [x] Benchmark `run!` and `collect_outputs` independently while retaining an + end-to-end benchmark comparable to the historical XPalm fixture. +- [x] Preallocate regular-cadence requested output columns from the known step + count where possible. +- [x] Extend output storage incrementally when lifecycle changes introduce a + newly requested object. +- [x] Avoid using the generic temporal-dependency stream representation for + ordinary requested outputs when a typed columnar representation is + available. Dependency-only values use the bounded + `TemporalDependencyBuffer`, while requested and `outputs=:all` histories + use preallocated `Vector{Tuple{Float64,T}}` streams; the unified runtime + suite asserts both representations. +- [x] Preserve streams keyed by application, object, and variable and preserve + resampling and removed-object history. +- [x] Compare `outputs=:none`, a small explicit request, the XPalm regression + request, and `outputs=:all`. + +### Performance benchmark and regression matrix + +- [x] Turn the existing BenchmarkTools suite into an executable, saved, + comparable suite instead of leaving `tune!`, `run`, and result saving + commented out. +- [x] Record source revisions, Julia version, manifest hash, fixture hash, + machine identity, warm-up policy, median time, minimum time, memory, and + allocations with every persisted result. +- [x] Add benchmarks for: + 1. initial XPalm scene compilation; + 2. a clean steady-state step; + 3. 100 and 1,000 clean steps; + 4. one lifecycle event on small and large existing scenes; + 5. a lifecycle event followed by immediate hard-call initialization; + 6. a temporal-policy-heavy scene with no requested output; + 7. the 4,160-step XPalm run with `outputs=:none`; + 8. the same run with reference outputs; + 9. output collection alone; + 10. the complete end-to-end reference helper. +- [x] Keep a short deterministic PR gate for steady-state allocations, + lifecycle scaling, and a simulation prefix. +- [ ] Run and persist the complete XPalm performance suite on a stable runner + nightly, before releases, or on explicit performance workflows. + `.github/workflows/FullPerformance.yml` now provisions the pinned + Ubuntu 24.04 / Julia 1.12.1 runner, executes the complete correctness-first + profile on nightly, version-tag, and manual triggers, and retains both + the CSV measurements and resolved manifest for 90 days. Keep this item + open until the first remote artifact has completed successfully. +- [x] Compare noisy wall-clock measurements with generous regression + tolerances, but keep allocation and work-count regressions strict. +- [x] Fail correctness before considering performance: every benchmark fixture + must verify final state or selected checkpoints. + +### Performance acceptance targets + +- [x] First restoration milestone: complete the exact XPalm full-cycle + reference in at most 15 seconds on the baseline machine after warm-up. + Committed revision `52ab05c7` passes at `14.995239` seconds with the + exact historical trajectory and final state. +- [x] Final target: complete it in at most 10 seconds, or document and obtain + explicit approval for any remaining measured cost required by the new + semantics. Committed revision `64ef6a6d` passes at `9.845081` seconds. +- [x] Demonstrate that a clean steady-state step allocates no memory in the + homogeneous target loops and stays within an explicitly recorded total + scheduler allocation budget. +- [x] Demonstrate that adding one equivalent organ to a large scene constructs + only the new and affected compiled targets. The benchmark and work + counters must show delta scaling rather than whole-scene scaling. +- [x] Demonstrate that sparse hard-call declarations do not slow unrelated + applications onto the call-capable path. +- [x] Demonstrate bounded temporal dependency memory when outputs are not + retained. +- [x] Persist the repaired baseline so later API, lifecycle, temporal, and + output changes cannot silently recreate a large regression. The warmed + full-cycle target enforces the complete historical final state and a + generous 20-second noisy-run guard; the dedicated XPalm fixture enforces + the monthly trajectory, harvest events, and summary. + +## Progressive Documentation Contract + +### Teaching rules + +- [x] Every page states the one new concept it introduces. +- [x] Every page starts from a working previous state or a minimal standalone + example. +- [x] Each page shows the smallest meaningful diff from the previous journey. +- [x] Do not introduce a selector, scope, backend protocol, temporal policy, + hard call, lifecycle operation, or output-routing policy before it is + needed. +- [x] Use one small diagram only when it makes object ownership, coupling, or + execution order clearer than prose. +- [x] Include one relevant explanation/diagnostic at each stage rather than + presenting every diagnostic at once. +- [x] End each page with: + - what the user added; + - what PlantSimEngine inferred; + - what remains explicit; + - the small set of new API names. +- [x] Keep migration and maintainer design documents outside the first-time + navigation. +- [x] Run every code block as a doctest or an included tested example. + +The docs test project now enforces the concept statement and four-part recap +on every progressive page. Ordinary user pages contain only executable +Documenter `@example` blocks; model-author source excerpts come from the +shipped examples covered by the focused composition suite. The gate passes 112 +journey-structure assertions plus the full Documenter build. + +### Simulation-user journey + +#### Journey 0: Mental model + +- [x] Explain process, model, application, object, status, environment, and + simulation in one short page. +- [x] State that PlantSimEngine is a composition/runtime framework, not a + scientific plant-model library or prescribed plant architecture. +- [x] Avoid executable configuration details on this page. + +#### Journey 1: Couple models on one object and run many timesteps + +This is the first executable journey and must demonstrate immediately that a +composite simulation naturally runs over time. + +- [x] Couple at least two existing models on one object and one scale. +- [x] Prefer the existing chain: + - `ToyDegreeDaysCumulModel`; + - `ToyLAIModel`; + - optionally `Beer` as the third model when the extra output improves the + story. +- [x] Run for several timesteps, not one: + + ```julia + simulation = run!(model; steps=30, outputs=:all) + results = collect_outputs(simulation) + ``` + +- [x] Show a short table or plot in which cumulative thermal time and LAI + visibly evolve. +- [x] Explain automatic same-object coupling by matching model outputs to + inputs. +- [x] Show the final state and the retained timeline. +- [x] Optionally show one additional `step!` or `continue!` call after the main + run, without turning continuation into a separate advanced subject. +- [x] Use a simple weather fixture if needed, but treat it only as supplied + forcing data here. Do not teach providers, handles, spatial binding, or + mutable environments yet. +- [x] Do not use explicit `ModelSpec`, selectors, templates, hard calls, + multiple cadences, or lifecycle mutation. + +#### Journey 2: Run the coupling on several same-scale objects + +- [x] Reuse the first model chain on two or more objects at the same scale. +- [x] Introduce stable object identity and one `Many` target selection. +- [x] Show that every object has independent status and output streams. +- [x] Do not introduce parent/child scales or templates yet. + +The new Start-here path now contains a configuration-free mental model, the +30-day one-object thermal-time/LAI/Beer chain, and the smallest two-object +extension using one shared `Many` selector. Each executable block ran during a +complete Documenter build through the dedicated docs test project. Migration +has its own navigation section, while design notes, audits, handoffs, internal +API material, and the roadmap are grouped under Maintainers. + +#### Journey 3: Build one multiscale plant + +- [x] Add one plant and several organs. +- [x] Introduce parent/child topology, cross-object inputs, and plant-local + aggregation one at a time. +- [x] Prefer existing models designed for this purpose: + - `ToyLeafSurfaceModel`; + - `ToyPlantLeafSurfaceModel`; + - `ToyLAIfromLeafAreaModel`; + - `ToyLightPartitioningModel`; + - `ToyMaintenanceRespirationModel`; + - `ToyPlantRmModel`. +- [x] Introduce `Subtree()` and `SelfPlant()` with a diagram showing their + exact scopes. +- [x] Introduce vector/reference carriers only after a scalar cross-object + example works. + +The one-plant journey extends the preceding explicit-object example with a +plant and two leaves. It first partitions a supplied plant scalar to the leaves +through `SelfPlant()`, then replaces supplied surfaces with +`ToyLeafSurfaceModel` and a plant-local `ToyPlantLeafSurfaceModel` aggregation +through `Subtree()`. Its scope diagram, scalar references, `RefVector`, final +states, and binding diagnostics execute in the docs build; the same composition +is protected by eight focused tutorial regression checks. + +#### Journey 4: Instantiate several plants + +- [x] Convert the previous plant into a `CompositeModelTemplate`. +- [x] Instantiate it at least twice with independent initial values. +- [x] Prove that plant-local selectors do not mix organs between plants. +- [x] Introduce `SceneScope()` only when adding a deliberately shared scene or + soil source. +- [x] Demonstrate one override only after the two identical instances work. + +The several-plants journey mounts the one-plant applications from a single +`CompositeModelTemplate` on two instances with independent initial light and +biomass. Exact surface totals and per-plant light sums prove that `Subtree()` and +`SelfPlant()` do not cross instance boundaries. Only after that base scenario +runs, a third instance overrides `:leaf_surface`, doubling its surface without +changing the template wiring. `SceneScope()` is intentionally deferred until a +shared scene or soil source exists. + +#### Journey 5: Understand environments + +- [x] Return to the forcing used in Journey 1 and now explain the environment + contract explicitly. +- [x] Show model environment declarations, `Environment(...)`, source + remapping, and global sampling. +- [x] Correct every reused example model so it declares every environment + variable it reads. +- [x] Run canonical examples against a strict backend that exposes only + declared variables. +- [x] Introduce spatial handles only after the global provider is understood. + +The environment journey first exposes the declarations already used by the +thermal-time and Beer kernels, then maps strict global source names with +`Environment(...; sources=...)`. Only after that succeeds does it switch the +unchanged Beer application to a small read-only `ToySpatialEnvironment`; two +leaf geometries compile to distinct `:sun` and `:shade` handles under one +`Many` application. The shipped backend example extends the canonical +`EnvironmentAPI` namespace, and its load order is covered by 259 API +stabilization checks in addition to 91 focused example checks. + +#### Journey 6: Give models different cadences + +Repeated execution of the whole composite was already introduced in Journey 1. +This journey introduces a different concept: applications with different +cadences. + +- [x] Reuse the same thermal-time, LAI, light, and growth models where + scientifically coherent. +- [x] Introduce one daily application into an otherwise hourly simulation. +- [x] Start with `HoldLast`. +- [x] Introduce `Integrate` or `Aggregate` in a separate subsection with a + physical-units explanation. +- [x] Explain `PreviousTimeStep` only when breaking a real same-step cycle. +- [x] Do not combine multirate, growth, and mutable environment changes in the + first multirate example. + +The cadence journey keeps the existing thermal-time/LAI/Beer chain, changing +only application clocks and the LAI binding. `HoldLast` exposes a daily state +to 25 hourly light executions. A separate two-leaf rate example explains why +`Integrate` yields `(1 + 2) × 24 × 3600 = 259200` rate-seconds and contrasts it +with `Aggregate`. Because neither example contains feedback, the page +explicitly defers `PreviousTimeStep` to a real cycle. The focused tutorial, +multirate integration, and full docs gates all pass. + +#### Journey 7: Modify plant structure + +- [x] Grow one plant by adding one organ. +- [x] Explain that structural changes refresh after the mutating application: + later application groups may see them in the current timestep, while + already-completed groups do not; external changes between steps refresh + before the next timestep. +- [x] Add removal and reparenting only after creation works. +- [x] Reuse existing carbon demand/allocation/biomass models when practical: + - `ToyCDemandModel`; + - `ToyCAllocationModel`; + - `ToyCBiomassModel`; + - `ToyAssimModel`. +- [x] Verify conservation, parentage, refreshed application targets, and + retained history. + +The structure journey runs `ToyCDemandModel` into `ToyCBiomassModel` under the +explicit all-demand-is-accepted assumption, adds `:leaf_3`, then reparents it +and removes `:leaf_2` in later stages. It shows the stale simulation plan before +refresh and the extended plan afterward, verifies every retained +`demand ≈ biomass_increment + growth_respiration` sample, and confirms output +history counts of 4, 3, and 3 for the persistent, removed, and newly created +leaves. It also corrects the older growth page to the actual after-application +refresh contract. + +#### Journey 8: Modify the environment + +- [x] Begin with a single-layer mutable environment. +- [x] Show the current typed trial-state path: + + ```julia + run_call!( + context, + :energy_balance; + environment=trial_environment, + publish=false, + ) + ``` + +- [x] Show an explicit accepted commit: + + ```julia + commit_environment!(context, accepted_environment) + ``` + +- [x] Explain environment output declarations as commit permissions. +- [x] Then extend to a spatial/voxel environment, proving that `Many` preserves + each target's opaque compiled handle. +- [x] Do not expose backend implementation protocol in the ordinary user page; + place backend authoring in an extension guide. + +The mutable-environment journey first runs one reader against a typed trial +temperature without publication, then commits and publishes a distinct +accepted temperature. Its focused test verifies the committed backend state +and that the reader retained exactly one accepted sample. A two-cell `Many` +case proves that the same application retains distinct `:sun` and `:shade` +compiled handles. The backend protocol is documented separately in +`guides/extensions/environment_backends.md`; 116 focused toy checks, 259 +public-API stabilization checks, and the full documentation build pass. + +#### Journey 9: Advanced execution control + +- [x] Teach hard calls, target inspection, selective target calls, iterative + publication, duplicate writers, `Updates`, and output routing. +- [x] Make `publish=false` trial semantics and accepted publication explicit. +- [x] Keep these APIs out of all earlier examples unless scientifically + necessary. + +The advanced execution page declares a call-only `Many` reader, inspects its +vector-like targets, restricts execution explicitly by `ObjectId`, runs two +unpublished trials, and publishes one accepted target call. A separate stock +example proves that unordered canonical writers are rejected, `Updates` +establishes the intentional order, and `output_routing=(stock=:stream_only,)` +retains an alternative stream without changing canonical final state. Building +that example exposed and fixed a runtime defect where stream-only kernels still +shared the canonical reference; private application-local output references +now persist across timesteps. The 130-check toy suite, 640-check unified +runtime suite, 49-check hard-call suite, 14-check output-boundary suite, +10-check runtime matrix, 259-check API stabilization suite, and full +documentation build pass. + +#### Journey 10: Complete realistic example + +- [x] Use the MAESPA-style example as the integrated reference. +- [x] Include multiple plants, multiple scales, multiple cadences, dynamic + structure where relevant, and mutable/spatial environments. +- [x] Present it as a synthesis and reference, not as onboarding. +- [x] Link every advanced construct back to the earlier page that introduced + it independently. + +The synthesis page executes the tested 25-hour MAESPA-style stand with two +species instances, five leaves, scene/plant/internode/leaf/soil scales, hourly +canopy and soil exchange, daily LAI and allocation, parent-controlled leaf +calls, and an explicitly committed single-layer canopy environment. It +inspects instances, schedule, calls, live-reference carriers, compiled +environment routes, final state, and retained cadence counts. A cross-reference +table links each integrated mechanism back to its focused journey. The page +also states why topology remains fixed for this short canopy-energy question +and links dynamic-growth users to the independent structure journey. Making +the source runnable outside the test harness moved its backend extensions to +the canonical `EnvironmentAPI` namespace. The 174-check MAESPA suite and full +documentation build pass. + +### Model-developer journey + +- [x] **Model 1:** implement a parameterized model with required inputs, + defaults, outputs, and the final one-step kernel. +- [x] **Model 2:** couple it automatically with another model on one object. +- [x] **Model 3:** prove the same kernel runs over several objects without an + object loop inside the model. +- [x] **Model 4:** consume a scalar cross-object value. +- [x] **Model 5:** consume or produce a vector-like multiscale value. +- [x] **Model 6:** declare sampled environment inputs. +- [x] **Model 7:** declare cadence and output temporal semantics. +- [x] **Model 8:** declare and execute a hard dependency. +- [x] **Model 9:** produce and explicitly commit accepted mutable environment + state. +- [x] Cross-link each model-developer page to the corresponding simulation-user + journey, without forcing ordinary users through implementation details. + +The model-developer path starts from one parameterized +`ToyDevelopmentModel`, first running it directly and then composing it on one +and several objects. Separate pages introduce scalar and `RefVector` +cross-object values, sampled environments and model-level clocks, model-author +hard-call defaults, and accepted mutable-environment commits. Each page links +back to the user journey where the same runtime concept first appears. All +executable blocks pass in the full documentation build, and 195 focused checks +cover the 22 reusable model declarations plus the nine developer compositions. + +## Existing Model Reuse Inventory + +Inspect and update these models before inventing new tutorial types: + +| Existing model or helper | Preferred teaching use | +|---|---| +| `ToyDegreeDaysCumulModel` | Repeated timesteps, accumulated state, first coupling | +| `ToyLAIModel` | Automatic same-object input/output coupling | +| `Beer` | Third model in a simple chain; later, environment inputs | +| `ToyRUEGrowthModel` | Stateful biomass accumulation and later growth | +| `ToyAssimGrowthModel` | Model switching or comparison with a decomposed chain | +| `ToyLeafSurfaceModel` | One kernel over many leaf objects | +| `ToyPlantLeafSurfaceModel` | Plant-local aggregation | +| `ToyLAIfromLeafAreaModel` | Cross-scale aggregation toward scene/plant LAI | +| `ToyLightPartitioningModel` | Larger-scale value partitioned to organs | +| `ToyMaintenanceRespirationModel` | Organ-level process | +| `ToyPlantRmModel` | Plant-level aggregation from organs | +| `ToyAssimModel` | Organ assimilation in a decomposed carbon chain | +| `ToyCDemandModel` | Organ demand | +| `ToyCAllocationModel` | Vector-of-organ allocation | +| `ToyCBiomassModel` | Accepted allocation applied to organ biomass | +| `ToySoilWaterModel` | Shared soil/environment-related examples | +| `import_mtg_example()` | MTG import after the explicit object API is understood | +| `maespa_model_example.jl` | Final mutable/spatial environment synthesis | + +Before using any of them: + +- [x] Ensure its input, output, environment, and timing declarations match what + its kernel actually reads and writes. +- [x] Ensure it uses the final model kernel and accesses its own parameters + through the `model` argument. +- [x] Ensure its docstring uses current `CompositeModel`/`Object` terminology. +- [x] Ensure its numeric fields remain generic where scientifically possible. +- [x] Add direct contract tests and at least one composition test. + +The reusable model audit now checks all 22 teaching models directly, including +the models introduced for environment control, advanced execution, and the +model-developer path. Both environment input and environment output +declarations are checked. The environment-reading thermal-time, Beer, and +maintenance-respiration models declare exactly the fields their kernels +sample. The canonical coupled chain runs with `Float32` parameters and status +against forcing containing only `T`, `Ri_PAR_f`, and the required timeline +duration; omitting `Ri_PAR_f` fails environment validation. The focused +contract/composition suite passes 195 checks, and the six-part +internal/downstream benchmark smoke suite passes all 35 checks. + +## Documentation Navigation Target + +- [x] **Start here** + 1. Why PlantSimEngine; + 2. mental model; + 3. first same-scale coupled simulation over many timesteps; + 4. several same-scale objects. +- [x] **Structure and composition** + 1. one multiscale plant; + 2. several plants; + 3. templates and overrides; + 4. MTG import. +- [x] **Environment and time** + 1. read an environment; + 2. spatial environments; + 3. different model cadences; + 4. temporal policies. +- [x] **Dynamic and advanced simulations** + 1. structural growth; + 2. mutable environments; + 3. hard calls and iterative execution; + 4. complete MAESPA-style example. +- [x] **Implement models** + 1. basic contract; + 2. composition; + 3. multiple objects; + 4. multiscale values; + 5. environment-aware models; + 6. hard dependencies. +- [x] **Reference** + 1. ordinary public API; + 2. diagnostics; + 3. backend extension API; + 4. graph editor; + 5. evaluation. + +The Reference navigation contains the ordinary Public API and symbol +inventory, troubleshooting and diagnostics pages, the backend extension +contract, graph editor guide, and `Evaluation` fitting/metrics material. +- [x] **Migration** + - keep migration from the removed mapping runtime separate from onboarding. +- [x] **Maintainers** + - move design notes, implementation plans, completion audits, and handoff + documents out of the normal user journey. + +## Downstream Migration Contract + +### PlantBiophysics: model-developer acceptance suite + +PlantBiophysics validates the model-author side of the API. + +- [x] Preserve its current `multi-plant` branch work. +- [x] Migrate every model kernel to the final signature. +- [x] Migrate model parameters away from the redundant `models` bundle. +- [x] Migrate environment input/output declarations and kernel argument names. +- [x] Migrate hard-call code and the fine-grained sampled-environment escape + hatch. +- [x] Migrate required/default input declarations. +- [x] Migrate evaluation functions if they move under + `PlantSimEngine.Evaluation`. +- [x] Update all PlantBiophysics simulations, fitting code, tests, and docs. +- [x] Run its package tests and documentation build through a Kaimon session + started for the PlantBiophysics checkout. + +Final downstream evidence: PlantBiophysics commits `0d110a9`, `b79dc18`, +`d252b6b`, and `b733e05` migrate required/default declarations, direct +`ModelSpec` construction, the focused `Evaluation` namespace, and the +documentation gate. Kaimon run 1285 passes the complete 268-check package +suite, including embedded doctests, and run 1286 passes the standalone +documentation build. + +### XPalm: simulation-user and framework-builder acceptance suite + +XPalm validates the scenario-construction side of the API. + +- [x] Preserve its current `multi-plant` branch work and existing commits. +- [x] Migrate every model kernel and environment declaration. +- [x] Migrate its scenario compiler to the single canonical `ModelSpec` syntax. +- [x] Migrate selectors and scopes without weakening plant-local isolation. +- [x] Verify templates, multiple plants, scene and soil sharing, lifecycle + changes, updates, output requests, and collection. +- [x] Update XPalm docs and extension/notebook examples. +- [x] Run its package tests and documentation build through a Kaimon session + started for the XPalm checkout. + +Final downstream evidence: XPalm commits `356ab43`, `4f70803`, `9db5666`, +`60d0375`, and `460d3b5` migrate the scenario compiler, diagnostics and +notebook-facing examples, canonical initial-organ respiration publication, +and the documentation gate. From the final PlantSimEngine source state, +Kaimon run 1321 passes all 307 package checks, run 1312 passes the docs build, +and run 1322 passes all 68 v0.6.1 full-cycle numerical-regression checks. No +reference fixture changed; the pre-existing uncommitted `CHANGELOG.md` edit +remains untouched and unstaged. + +### Cross-repository completion rule + +No breaking PlantSimEngine milestone is complete until: + +1. PlantSimEngine examples, tests, and docs use the new contract; +2. PlantBiophysics uses and tests it; +3. XPalm uses and tests it; +4. no compatibility method remains solely to keep either downstream package + temporarily green. + +## Required Validation + +### Static checks + +- [x] `git diff --check` passes in every modified repository. +- [x] No stale removed environment API names remain: + + ```text + with_environment! + update_environment! + EnvironmentSupport + scatter_environment_outputs! + ``` + +- [x] No stale six-argument model kernel remains. +- [x] No stale old environment trait names remain after the vocabulary rename. +- [x] No deprecated mapping-runtime terminology remains in current user docs. +- [x] README, tutorials, API inventory, generated graph labels, docstrings, and + error messages use the same vocabulary. + +Final static audit: searches across the three packages' source, README, and +ordinary user documentation find no old environment traits, no removed +environment entry points, and no model kernel carrying the old `models` +argument. References to removed mapping APIs are confined to the dedicated +migration page and maintainer records. The removed environment names remain +only in negative tests that assert their absence. + +### PlantSimEngine behavior + +- [x] One object and several objects. +- [x] Same-object and cross-object inputs. +- [x] `One`, `OptionalOne`, and `Many`. +- [x] One scale, several scales, and several plant instances. +- [x] Hard calls, nested hard calls, trials, accepted publication, and accepted + environment commits. +- [x] Duplicate writers and explicit update ordering. +- [x] Repeated timesteps and different application cadences. +- [x] `PreviousTimeStep`, `HoldLast`, `Interpolate`, `Integrate`, and + `Aggregate`. +- [x] Global, spatial, transient, and mutable environments. +- [x] Distinct target handles under one `Many` trial environment. +- [x] Object creation, removal, reparenting, movement, and geometry updates. +- [x] Templates, instances, and overrides. +- [x] Output retention, collection, continuation, and removed-object history. +- [x] Generic numeric types and allocation-sensitive execution. + +The final unified, environment, lifecycle, multirate, output-boundary, update, +hard-call, template/override, generic-number, and allocation suites exercise +this matrix. Kaimon run 1352 passes the expanded 660-check unified runtime +suite; the complete run 1358 passes 1,895 checks with no failures or errors. + +### Test and documentation gates + +- [x] Run focused PlantSimEngine model-contract tests. +- [x] Run focused environment backend tests. +- [x] Run focused steady-state allocation and scheduler tests. +- [x] Run focused incremental lifecycle work-count and scaling tests. +- [x] Run focused temporal-buffer and sparse-hard-call performance tests. +- [x] Run the unified model/object API suite. +- [x] Run the MAESPA focused suite. +- [x] Run the full PlantSimEngine suite. +- [x] Run the short deterministic performance-regression gate. +- [x] Run and persist the complete XPalm performance matrix on the baseline + machine. +- [x] Build PlantSimEngine documentation and execute all doctests. +- [x] Run the full PlantBiophysics suite and documentation. +- [x] Run the full XPalm suite and documentation. +- [x] Record exact passing counts and commands from the final source state. + +Final Kaimon evidence from 2026-07-30: + +- PlantSimEngine project, no filter: run 1358, 1,895/1,895 passed. +- PlantSimEngine docs project, no filter: run 1359, 113/113 passed + (`Progressive journey structure` 112 and Documenter build 1). +- PlantSimEngine benchmark project, no filter: run 1360, 56/56 passed. +- Focused lifecycle scaling: run 1351, 319/319 passed; unified runtime: run + 1352, 660/660 passed; the 8-to-8,192-leaf allocation profile: run 1357, + 4/4 passed. +- Full persisted XPalm profile: run 1367, 4/4 passed; the ignored local result + is `benchmark/results/xpalm-full-latest.csv`. +- Exact warmed XPalm no-output gate: run 1364, 5/5 passed in + `8.830646833` seconds with `4.205151272 GB` and `44,848,098` + allocations. It finishes at step 4,160 with 344 phytomers, + `lai=5.0587602356164405`, and `ftsw=0.7991179101191216`. +- PlantBiophysics project: run 1365, 268/268 passed; docs project: run 1366, + 1/1 passed. +- XPalm project: run 1361, 307/307 passed; docs project: run 1362, 1/1 + passed; dedicated `reference-regression` mode through the temporary Kaimon + test wrapper: run 1363, 68/68 passed. + +All Julia executions used Kaimon `run_tests` with the recorded project path. +The XPalm reference mode used the package's existing +`test/runtests.jl reference-regression` entry point through a temporary wrapper +because Kaimon's test filter is a ReTest expression rather than an `ARGS` +forwarder. + +## Suggested Implementation Order + +1. [x] Snapshot all three dirty worktrees and identify ownership of overlapping + changes. +2. [x] Reproduce and profile the current XPalm performance baseline through a + healthy Kaimon session; add the runtime counters and benchmark matrix. +3. [x] Make lifecycle extension delta-based and verify that additions, + removals, reparenting, movement, and new-organ hard calls do not rebuild + the whole scene. +4. [x] Replace repeated scheduler scanning with linear application groups. +5. [x] Compile temporal policies into bounded typed state. +6. [x] Restore per-batch no-hard-call specialization. +7. [x] Reach and persist the first XPalm restoration milestone before layering + further breaking API work onto the runtime. +8. [x] Finish separating and preallocating requested outputs. +9. [x] Finalize the model kernel, environment vocabulary, and required/default + declarations. +10. [x] Migrate PlantSimEngine models and focused contract tests. +11. [x] Finalize `ModelSpec`, selectors, namespaces, and result ergonomics. +12. [x] Migrate XPalm early enough that it influences scenario API decisions + and continuously rerun its correctness and performance suites. +13. [x] Migrate PlantBiophysics early enough that it influences model API + decisions. +14. [x] Rebuild the progressive simulation-user journey using existing models. +15. [x] Rebuild the parallel model-developer journey. +16. [x] Move migration and maintainer material out of onboarding. +17. [x] Run the complete three-repository local correctness and performance + validation matrix. +18. [x] Make coherent milestone commits whenever possible. +19. [ ] Delete this temporary goal after durable documentation and final + validation evidence replace it. + +## Definition of Done + +This goal is complete only when a new user can: + +1. open the first executable page; +2. understand a two- or three-model same-scale coupling; +3. run it over many timesteps with a short `run!` call; +4. inspect its evolving outputs; +5. progress to several objects, multiscale, multiplant, environment, and + multirate concepts without encountering multiple new abstractions at once; +6. reach the MAESPA-style example after every major concept has already been + introduced independently; + +and when a model developer can implement the final kernel and declarations +without understanding the compiler, registry, backend handles, graph editor, +or output-retention internals. + +The same final API must compile, run, and pass tests in PlantSimEngine, +PlantBiophysics, and XPalm without compatibility layers. + +It must also execute the exact 4,160-step XPalm reference cycle in at most +10 seconds on the recorded baseline machine, unless a remaining cost required +by the new semantics has been measured, documented, and explicitly approved. +Steady-state execution must remain a simple compiled hot loop, structural +updates must scale with the changed objects rather than the whole scene, and +the persisted performance suite must protect those properties from regression. diff --git a/benchmark/Project.toml b/benchmark/Project.toml index 16cbac02d..0d1d915cc 100644 --- a/benchmark/Project.toml +++ b/benchmark/Project.toml @@ -7,6 +7,8 @@ PlantBiophysics = "7ae8fcfa-76ad-4ec6-9ea7-5f8f5e2d6ec9" PlantMeteo = "4630fe09-e0fb-4da5-a846-781cb73437b6" PlantSimEngine = "9a576370-710b-4269-adf9-4f603a9c6423" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" +Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" XPalm = "6b523e1e-d512-416c-8e51-a8fbef0064e7" diff --git a/benchmark/benchmarks.jl b/benchmark/benchmarks.jl index f3bdc06f7..0bad3ba1c 100644 --- a/benchmark/benchmarks.jl +++ b/benchmark/benchmarks.jl @@ -17,36 +17,162 @@ elseif Sys.islinux() suite_name = suite_name * "linux" end const SUITE = BenchmarkGroup() -SUITE[suite_name] = BenchmarkGroup(["PSE", "PBP", "XPalm"]) +const INCLUDE_DOWNSTREAM_BENCHMARKS = get( + ENV, + "PSE_BENCHMARK_INCLUDE_DOWNSTREAM", + get(ENV, "GITHUB_ACTIONS", "false") == "true" ? "false" : "true", +) == "true" +SUITE[suite_name] = BenchmarkGroup( + INCLUDE_DOWNSTREAM_BENCHMARKS ? ["PSE", "PBP", "XPalm"] : ["PSE"], +) # "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) +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()) +SUITE[suite_name]["PSE_multirate_no_output_run"] = @benchmarkable benchmark_multirate_no_output_run( + model, + nsteps, +) setup = ((model, ignored_requests, nsteps) = setup_multirate_buffer_benchmark()) + +include(joinpath(@__DIR__, "test-hard-call-path-benchmark.jl")) +for usage in (:zero, :sparse, :dense) + SUITE[suite_name]["PSE_hard_calls_$(usage)"] = + @benchmarkable benchmark_hard_call_path( + model, + nsteps, + ) setup = ((model, nsteps) = setup_hard_call_path_benchmark( + usage=$usage, + )) end -# "PBP benchmark" -include("test-plantbiophysics.jl") -SUITE[suite_name]["PBP"] = @benchmarkable benchmark_plantbiophysics() +SUITE[suite_name]["PSE_lifecycle_small"] = + @benchmarkable benchmark_lifecycle_event( + simulation, + new_index, + ) setup = ((simulation, new_index) = + setup_lifecycle_hard_call_benchmark( + nobjects=32, + usage=:zero, + )) +SUITE[suite_name]["PSE_lifecycle_large"] = + @benchmarkable benchmark_lifecycle_event( + simulation, + new_index, + ) setup = ((simulation, new_index) = + setup_lifecycle_hard_call_benchmark( + nobjects=5000, + usage=:zero, + )) +SUITE[suite_name]["PSE_lifecycle_immediate_hard_call"] = + @benchmarkable benchmark_lifecycle_event( + simulation, + new_index, + ) setup = ((simulation, new_index) = + setup_lifecycle_hard_call_benchmark( + nobjects=1000, + usage=:dense, + )) + +if INCLUDE_DOWNSTREAM_BENCHMARKS + # "PBP benchmark" + include(joinpath(@__DIR__, "test-plantbiophysics.jl")) + SUITE[suite_name]["PBP"] = @benchmarkable benchmark_plantbiophysics() + SUITE[suite_name]["PBP_batch_run"] = + @benchmarkable benchmark_plantbiophysics_batch( + scenes, + ) setup = (scenes = setup_benchmark_plantbiophysics_batch()) + + # "XPalm benchmark" + include(joinpath(@__DIR__, "test-xpalm.jl")) + include(joinpath(@__DIR__, "performance_regression.jl")) + const XPALM_PR_BENCHMARK_STEPS = 100 + SUITE[suite_name]["XPalm_setup_100"] = + @benchmarkable xpalm_default_param_create( + nsteps=XPALM_PR_BENCHMARK_STEPS, + ) seconds = 30 -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]["XPalm_run_100"] = + @benchmarkable xpalm_default_param_run( + model, + requests, + nsteps, + ) setup = ((model, requests, nsteps) = xpalm_default_param_create(; + nsteps=XPALM_PR_BENCHMARK_STEPS, + )) -# "XPalm benchmark" -include("test-xpalm.jl") -SUITE[suite_name]["XPalm_setup"] = @benchmarkable xpalm_default_param_create() seconds = 120 + SUITE[suite_name]["XPalm_reference_outputs_100"] = + @benchmarkable xpalm_reference_param_run( + model, + requests, + nsteps, + ) setup = ((model, requests, nsteps) = xpalm_reference_param_create(; + nsteps=XPALM_PR_BENCHMARK_STEPS, + )) -palm, models, out_vars, meteo = xpalm_default_param_create() -sim_outputs = xpalm_default_param_run(palm, models, out_vars, meteo) + SUITE[suite_name]["XPalm_no_outputs_100"] = + @benchmarkable xpalm_reference_param_run( + model, + requests, + nsteps; + outputs=:none, + ) setup = ((model, requests, nsteps) = xpalm_reference_param_create(; + nsteps=XPALM_PR_BENCHMARK_STEPS, + )) -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_small_outputs_100"] = + @benchmarkable xpalm_reference_param_run( + model, + requests, + nsteps, + ) setup = ((model, requests, nsteps) = xpalm_small_param_create(; + nsteps=XPALM_PR_BENCHMARK_STEPS, + )) -#tune!(SUITE) -#results = run(SUITE, verbose=true) -#BenchmarkTools.save(dirname(@__FILE__) * "/output.json", median(results)) + SUITE[suite_name]["XPalm_all_outputs_100"] = + @benchmarkable xpalm_reference_param_run( + model, + OutputRequest[], + nsteps; + outputs=:all, + ) setup = ((model, nsteps) = xpalm_reference_model_create(; + nsteps=XPALM_PR_BENCHMARK_STEPS, + )) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + tune!(SUITE) + results = run(SUITE; verbose=true) + default_name = + "benchmark-$(Dates.format(Dates.now(), dateformat"yyyymmdd-HHMMSS")).json" + output_path = get( + ENV, + "PSE_BENCHMARK_OUTPUT", + joinpath(@__DIR__, "results", default_name), + ) + mkpath(dirname(output_path)) + BenchmarkTools.save(output_path, median(results)) + if INCLUDE_DOWNSTREAM_BENCHMARKS + summary_path = replace(output_path, r"\.[^.]+$" => "-summary.csv") + metadata = _performance_metadata(; + warmup_policy="BenchmarkTools tune plus per-benchmark setup", + ) + write_benchmark_summary(summary_path, results, metadata) + @info "PlantSimEngine benchmark suite complete" output_path summary_path + else + @info "PlantSimEngine benchmark suite complete" output_path + end +end diff --git a/benchmark/performance_regression.jl b/benchmark/performance_regression.jl new file mode 100644 index 000000000..7d858a444 --- /dev/null +++ b/benchmark/performance_regression.jl @@ -0,0 +1,792 @@ +using BenchmarkTools +using CSV +using DataFrames +using Dates +using PlantSimEngine +using SHA +using Sockets +using Statistics +using XPalm + +isdefined(@__MODULE__, :xpalm_reference_param_create) || + include(joinpath(@__DIR__, "test-xpalm.jl")) + +const PERFORMANCE_SMOKE_STEPS = 2 +const PERFORMANCE_SHORT_STEPS = 100 +const PERFORMANCE_MEDIUM_STEPS = 1000 +const PERFORMANCE_FULL_STEPS = 4160 +const PERFORMANCE_STATISTICAL_SAMPLES = 3 + +function _performance_git_revision(path) + try + return readchomp(`git -C $path rev-parse HEAD`) + catch + return "unknown" + end +end + +function _performance_path_hash(path) + if isfile(path) + return bytes2hex(SHA.sha256(read(path))) + elseif isdir(path) + entries = String[] + for (root, directories, files) in walkdir(path) + sort!(directories) + for file in sort!(files) + file_path = joinpath(root, file) + relative_path = relpath(file_path, path) + push!( + entries, + string( + relative_path, + '\0', + bytes2hex(SHA.sha256(read(file_path))), + ), + ) + end + end + return bytes2hex(SHA.sha256(codeunits(join(entries, '\n')))) + end + return "missing" +end + +function _performance_fixture_hash(xpalm_root) + paths = ( + joinpath(xpalm_root, "0-data", "meteo.csv"), + joinpath( + xpalm_root, + "test", + "references", + "regression", + "v0.6.1", + ), + ) + entries = String[ + string(relpath(path, xpalm_root), '\0', _performance_path_hash(path)) + for path in paths + ] + return bytes2hex(SHA.sha256(codeunits(join(entries, '\n')))) +end + +function _performance_metadata(; warmup_policy) + pse_root = dirname(@__DIR__) + xpalm_root = dirname(dirname(pathof(XPalm))) + plantbiophysics_source = Base.find_package("PlantBiophysics") + plantbiophysics_root = isnothing(plantbiophysics_source) ? + nothing : + dirname(dirname(plantbiophysics_source)) + manifest_path = joinpath(pse_root, "benchmark", "Manifest.toml") + return ( + recorded_at=Dates.format(Dates.now(), dateformat"yyyy-mm-ddTHH:MM:SS.sss"), + julia_version=string(VERSION), + hostname=Sockets.gethostname(), + machine=Sys.MACHINE, + cpu=Sys.CPU_NAME, + memory_bytes=Sys.total_memory(), + threads=Threads.nthreads(), + plantsimengine_revision=_performance_git_revision(pse_root), + plantbiophysics_revision=isnothing(plantbiophysics_root) ? + "unavailable" : + _performance_git_revision( + plantbiophysics_root, + ), + xpalm_revision=_performance_git_revision(xpalm_root), + manifest_hash=_performance_path_hash(manifest_path), + fixture_hash=_performance_fixture_hash(xpalm_root), + warmup_policy=warmup_policy, + ) +end + +function _benchmark_summary_records( + results, + metadata; + benchmark_path=String[], +) + records = NamedTuple[] + for (name, result) in pairs(results) + path = [benchmark_path; string(name)] + if result isa BenchmarkTools.Trial + median_estimate = BenchmarkTools.median(result) + minimum_estimate = BenchmarkTools.minimum(result) + push!( + records, + merge( + metadata, + ( + benchmark=join(path, "/"), + samples=length(result), + median_time_ns=median_estimate.time, + minimum_time_ns=minimum_estimate.time, + median_memory_bytes=median_estimate.memory, + minimum_memory_bytes=minimum_estimate.memory, + median_allocations=median_estimate.allocs, + minimum_allocations=minimum_estimate.allocs, + ), + ), + ) + else + append!( + records, + _benchmark_summary_records( + result, + metadata; + benchmark_path=path, + ), + ) + end + end + return records +end + +function write_benchmark_summary(path, results, metadata) + records = _benchmark_summary_records(results, metadata) + mkpath(dirname(path)) + CSV.write(path, DataFrame(records)) + return records +end + +function _performance_record!( + records, + metadata, + profile, + stage, + metric, + value, + unit, +) + push!( + records, + merge( + metadata, + ( + profile=String(profile), + stage=String(stage), + metric=String(metric), + value=Float64(value), + unit=String(unit), + ), + ), + ) + return records +end + +function _checkpoint_performance_records(path, records) + isnothing(path) && return records + mkpath(dirname(path)) + CSV.write(path, DataFrame(records)) + return records +end + +function _performance_allocation_count(measurement) + stats = measurement.gcstats + return sum(( + Int(getproperty(stats, name)) + for name in (:malloc, :realloc, :poolalloc, :bigalloc) + if hasproperty(stats, name) + ); init=0) +end + +function _timed_performance_operation(operation) + GC.gc() + return @timed operation() +end + +function _measure_performance_stage!( + operation, + records, + metadata, + profile, + stage, + checkpoint_path=nothing, + ; + samples::Int=1, + sample_factory=nothing, +) + samples >= 1 || error("Performance stage samples must be positive.") + started_at = time_ns() + measurement = try + _timed_performance_operation(operation) + catch + _performance_record!( + records, + metadata, + profile, + stage, + :failed, + 1, + :count, + ) + _performance_record!( + records, + metadata, + profile, + stage, + :wall_time_before_failure, + (time_ns() - started_at) / 1.0e9, + :seconds, + ) + _checkpoint_performance_records(checkpoint_path, records) + rethrow() + end + measurements = Any[measurement] + for _ in 2:samples + sample_operation = isnothing(sample_factory) ? + operation : + sample_factory() + push!( + measurements, + _timed_performance_operation(sample_operation), + ) + end + times = getproperty.(measurements, :time) + memories = getproperty.(measurements, :bytes) + allocations = _performance_allocation_count.(measurements) + _performance_record!( + records, + metadata, + profile, + stage, + :wall_time, + measurement.time, + :seconds, + ) + _performance_record!( + records, + metadata, + profile, + stage, + :median_time, + median(times), + :seconds, + ) + _performance_record!( + records, + metadata, + profile, + stage, + :minimum_time, + minimum(times), + :seconds, + ) + _performance_record!( + records, + metadata, + profile, + stage, + :allocated, + measurement.bytes, + :bytes, + ) + _performance_record!( + records, + metadata, + profile, + stage, + :median_memory, + median(memories), + :bytes, + ) + _performance_record!( + records, + metadata, + profile, + stage, + :minimum_memory, + minimum(memories), + :bytes, + ) + _performance_record!( + records, + metadata, + profile, + stage, + :allocations, + allocations[1], + :count, + ) + _performance_record!( + records, + metadata, + profile, + stage, + :median_allocations, + median(allocations), + :count, + ) + _performance_record!( + records, + metadata, + profile, + stage, + :minimum_allocations, + minimum(allocations), + :count, + ) + _performance_record!( + records, + metadata, + profile, + stage, + :samples, + samples, + :count, + ) + _performance_record!( + records, + metadata, + profile, + stage, + :gc_time, + measurement.gctime, + :seconds, + ) + _checkpoint_performance_records(checkpoint_path, records) + return measurement.value +end + +function _record_runtime_performance!( + records, + metadata, + profile, + stage, + simulation, +) + performance = PlantSimEngine.Advanced.runtime_performance(simulation) + isnothing(performance) && return records + for (metric, value) in sort!(collect(performance.counts); by=first) + _performance_record!( + records, + metadata, + profile, + stage, + metric, + value, + :count, + ) + end + for (metric, value) in sort!( + collect(performance.elapsed_seconds); + by=first, + ) + _performance_record!( + records, + metadata, + profile, + stage, + metric, + value, + :seconds, + ) + end + return records +end + +function _record_xpalm_state!( + records, + metadata, + profile, + stage, + state, +) + for metric in (:current_step, :phytomer_count, :lai, :ftsw) + _performance_record!( + records, + metadata, + profile, + stage, + metric, + getproperty(state, metric), + :value, + ) + end + return records +end + +function _performance_steps(profile) + profile == :smoke && return PERFORMANCE_SMOKE_STEPS + profile == :short && return PERFORMANCE_SHORT_STEPS + profile == :medium && return PERFORMANCE_MEDIUM_STEPS + profile == :full && return PERFORMANCE_FULL_STEPS + error( + "Unsupported performance profile `$(profile)`. Use `:smoke`, `:short`, ", + "`:medium`, or `:full`.", + ) +end + +function _warmup_xpalm_performance!(profile_steps) + lifecycle_steps = min(profile_steps, PERFORMANCE_SHORT_STEPS) + no_output_model, no_output_steps = + xpalm_reference_model_create(; nsteps=lifecycle_steps) + xpalm_reference_param_run( + no_output_model, + OutputRequest[], + no_output_steps; + outputs=:none, + ) + + reference_model, reference_requests, reference_steps = + xpalm_reference_param_create(; nsteps=PERFORMANCE_SMOKE_STEPS) + reference_simulation = xpalm_reference_param_run( + reference_model, + reference_requests, + reference_steps, + ) + xpalm_default_param_collect_outputs(reference_simulation) + return nothing +end + +function run_xpalm_performance_profile(; + profile=:short, + checkpoint_path=nothing, +) + normalized_profile = Symbol(profile) + nsteps = _performance_steps(normalized_profile) + warmup_policy = + "unmeasured outputs=:none prefix ($(min(nsteps, PERFORMANCE_SHORT_STEPS)) steps) " * + "plus requested-output smoke ($(PERFORMANCE_SMOKE_STEPS) steps)" + _warmup_xpalm_performance!(nsteps) + metadata = _performance_metadata(; warmup_policy=warmup_policy) + records = NamedTuple[] + + compile_model, _ = xpalm_reference_model_create(; nsteps=nsteps) + initial_compilation = _measure_performance_stage!( + records, + metadata, + normalized_profile, + :initial_scene_compilation, + checkpoint_path, + samples=PERFORMANCE_STATISTICAL_SAMPLES, + sample_factory=() -> begin + sample_model, _ = + xpalm_reference_model_create(; nsteps=nsteps) + return () -> + PlantSimEngine.Advanced.refresh_bindings!(sample_model) + end, + ) do + PlantSimEngine.Advanced.refresh_bindings!(compile_model) + end + _performance_record!( + records, + metadata, + normalized_profile, + :initial_scene_compilation, + :application_count, + length(initial_compilation.applications), + :count, + ) + + steady_model, _ = xpalm_reference_model_create(; nsteps=2) + steady_simulation = xpalm_reference_param_run( + steady_model, + OutputRequest[], + 1; + outputs=:none, + ) + steady_simulation = _measure_performance_stage!( + records, + metadata, + normalized_profile, + :clean_steady_state_step, + checkpoint_path, + samples=PERFORMANCE_STATISTICAL_SAMPLES, + sample_factory=() -> begin + sample_model, _ = + xpalm_reference_model_create(; nsteps=2) + sample_simulation = xpalm_reference_param_run( + sample_model, + OutputRequest[], + 1; + outputs=:none, + ) + return () -> continue!(sample_simulation) + end, + ) do + continue!(steady_simulation) + end + current_step(steady_simulation) == 2 || error( + "XPalm clean-step benchmark did not advance to step two.", + ) + + no_output_setup = _measure_performance_stage!( + records, + metadata, + normalized_profile, + :scene_construction_no_outputs, + checkpoint_path, + samples=PERFORMANCE_STATISTICAL_SAMPLES, + ) do + xpalm_reference_model_create(; nsteps=nsteps) + end + no_output_model, no_output_steps = no_output_setup + no_output_simulation = _measure_performance_stage!( + records, + metadata, + normalized_profile, + :simulation_no_outputs, + checkpoint_path, + samples=PERFORMANCE_STATISTICAL_SAMPLES, + sample_factory=() -> begin + sample_model, sample_steps = + xpalm_reference_model_create(; nsteps=nsteps) + return () -> xpalm_reference_param_run( + sample_model, + OutputRequest[], + sample_steps; + outputs=:none, + performance=true, + ) + end, + ) do + xpalm_reference_param_run( + no_output_model, + OutputRequest[], + no_output_steps; + outputs=:none, + performance=true, + ) + end + _record_runtime_performance!( + records, + metadata, + normalized_profile, + :simulation_no_outputs, + no_output_simulation, + ) + _checkpoint_performance_records(checkpoint_path, records) + + small_setup = _measure_performance_stage!( + records, + metadata, + normalized_profile, + :scene_and_request_compile_small_outputs, + checkpoint_path, + samples=PERFORMANCE_STATISTICAL_SAMPLES, + ) do + xpalm_small_param_create(; nsteps=nsteps) + end + small_model, small_requests, small_steps = small_setup + small_simulation = _measure_performance_stage!( + records, + metadata, + normalized_profile, + :simulation_small_outputs, + checkpoint_path, + samples=PERFORMANCE_STATISTICAL_SAMPLES, + sample_factory=() -> begin + sample_model, sample_requests, sample_steps = + xpalm_small_param_create(; nsteps=nsteps) + return () -> xpalm_reference_param_run( + sample_model, + sample_requests, + sample_steps; + performance=true, + ) + end, + ) do + xpalm_reference_param_run( + small_model, + small_requests, + small_steps; + performance=true, + ) + end + _record_runtime_performance!( + records, + metadata, + normalized_profile, + :simulation_small_outputs, + small_simulation, + ) + + reference_setup = _measure_performance_stage!( + records, + metadata, + normalized_profile, + :scene_and_request_compile_reference_outputs, + checkpoint_path, + samples=PERFORMANCE_STATISTICAL_SAMPLES, + ) do + xpalm_reference_param_create(; nsteps=nsteps) + end + reference_model, reference_requests, reference_steps = reference_setup + reference_simulation = _measure_performance_stage!( + records, + metadata, + normalized_profile, + :simulation_reference_outputs, + checkpoint_path, + samples=PERFORMANCE_STATISTICAL_SAMPLES, + sample_factory=() -> begin + sample_model, sample_requests, sample_steps = + xpalm_reference_param_create(; nsteps=nsteps) + return () -> xpalm_reference_param_run( + sample_model, + sample_requests, + sample_steps; + performance=true, + ) + end, + ) do + xpalm_reference_param_run( + reference_model, + reference_requests, + reference_steps; + performance=true, + ) + end + reference_outputs = _measure_performance_stage!( + records, + metadata, + normalized_profile, + :collect_reference_outputs, + checkpoint_path, + samples=PERFORMANCE_STATISTICAL_SAMPLES, + ) do + xpalm_default_param_collect_outputs(reference_simulation) + end + _record_runtime_performance!( + records, + metadata, + normalized_profile, + :simulation_reference_outputs, + reference_simulation, + ) + + all_output_setup = _measure_performance_stage!( + records, + metadata, + normalized_profile, + :scene_construction_all_outputs, + checkpoint_path, + samples=PERFORMANCE_STATISTICAL_SAMPLES, + ) do + xpalm_reference_model_create(; nsteps=nsteps) + end + all_output_model, all_output_steps = all_output_setup + all_output_simulation = _measure_performance_stage!( + records, + metadata, + normalized_profile, + :simulation_all_outputs, + checkpoint_path, + samples=PERFORMANCE_STATISTICAL_SAMPLES, + sample_factory=() -> begin + sample_model, sample_steps = + xpalm_reference_model_create(; nsteps=nsteps) + return () -> xpalm_reference_param_run( + sample_model, + OutputRequest[], + sample_steps; + outputs=:all, + performance=true, + ) + end, + ) do + xpalm_reference_param_run( + all_output_model, + OutputRequest[], + all_output_steps; + outputs=:all, + performance=true, + ) + end + _record_runtime_performance!( + records, + metadata, + normalized_profile, + :simulation_all_outputs, + all_output_simulation, + ) + + no_output_state = xpalm_reference_final_state(no_output_simulation) + small_state = xpalm_reference_final_state(small_simulation) + reference_state = xpalm_reference_final_state(reference_simulation) + all_output_state = xpalm_reference_final_state(all_output_simulation) + _record_xpalm_state!( + records, + metadata, + normalized_profile, + :final_state_no_outputs, + no_output_state, + ) + _record_xpalm_state!( + records, + metadata, + normalized_profile, + :final_state_reference_outputs, + reference_state, + ) + _record_xpalm_state!( + records, + metadata, + normalized_profile, + :final_state_small_outputs, + small_state, + ) + _record_xpalm_state!( + records, + metadata, + normalized_profile, + :final_state_all_outputs, + all_output_state, + ) + _checkpoint_performance_records(checkpoint_path, records) + no_output_state == small_state == reference_state == all_output_state || error( + "XPalm performance fixtures diverged across output-retention modes: ", + "none=$(no_output_state), small=$(small_state), ", + "reference=$(reference_state), all=$(all_output_state).", + ) + isempty(reference_outputs) && error( + "XPalm performance reference output collection returned no tables.", + ) + + if normalized_profile == :full + xpalm_reference_state_matches(reference_state) || error( + "XPalm full-cycle performance fixture does not match the committed v0.6.1 final state: ", + "$(reference_state).", + ) + high_level_outputs = _measure_performance_stage!( + records, + metadata, + normalized_profile, + :historical_end_to_end_reference, + checkpoint_path, + samples=PERFORMANCE_STATISTICAL_SAMPLES, + ) do + xpalm_reference_end_to_end(; nsteps=nsteps) + end + xpalm_reference_high_level_state_matches(high_level_outputs) || error( + "XPalm historical end-to-end performance fixture does not match the committed ", + "v0.6.1 final state.", + ) + end + + return ( + records=records, + no_output_state=no_output_state, + small_state=small_state, + reference_state=reference_state, + all_output_state=all_output_state, + ) +end + +function write_xpalm_performance_profile(path; profile=:short) + result = run_xpalm_performance_profile(; + profile=profile, + checkpoint_path=path, + ) + _checkpoint_performance_records(path, result.records) + return result +end + +if abspath(PROGRAM_FILE) == @__FILE__ + profile = Symbol(get(ENV, "PSE_PERFORMANCE_PROFILE", "short")) + default_name = "xpalm-$(profile)-$(Dates.format(Dates.now(), dateformat"yyyymmdd-HHMMSS")).csv" + output_path = get( + ENV, + "PSE_PERFORMANCE_OUTPUT", + joinpath(@__DIR__, "results", default_name), + ) + result = write_xpalm_performance_profile(output_path; profile=profile) + @info "XPalm performance profile complete" profile output_path final_state=result.reference_state +end diff --git a/benchmark/prepare_full_performance_project.jl b/benchmark/prepare_full_performance_project.jl new file mode 100644 index 000000000..d46123cb3 --- /dev/null +++ b/benchmark/prepare_full_performance_project.jl @@ -0,0 +1,10 @@ +using TOML + +function prepare_full_performance_project!(project_path) + project = TOML.parsefile(project_path) + pop!(project, "sources", nothing) + open(project_path, "w") do io + TOML.print(io, project) + end + return project_path +end diff --git a/benchmark/test-PSE-benchmark.jl b/benchmark/test-PSE-benchmark.jl index 080f02b1a..056d0877d 100644 --- a/benchmark/test-PSE-benchmark.jl +++ b/benchmark/test-PSE-benchmark.jl @@ -15,10 +15,12 @@ end ToyInternodeCrazyEmergence(; TT_emergence=300.0) = ToyInternodeCrazyEmergence(TT_emergence) -PlantSimEngine.inputs_(m::ToyInternodeCrazyEmergence) = (TT_cu=-Inf,) +PlantSimEngine.inputs_(m::ToyInternodeCrazyEmergence) = (TT_cu=Required(Float64),) 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, status, environment, constants=nothing, context=nothing) + + model = runtime_model(context) #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,56 @@ 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,], - ), - MultiScaleModel( - model=ToyInternodeCrazyEmergence(TT_emergence=1.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=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(), - ), + applications = ( + ModelSpec(ToyDegreeDaysCumulModel(); name=:scene_degree_days, on=One(scale=:Scene)), + ModelSpec(ToyLAIModel(); name=:plant_lai, on=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, on=Many(scale=:Plant)), + ModelSpec(ToyPlantRmModel(); name=:plant_rm, on=Many(scale=:Plant), inputs=(:Rm_organs => Many(scale=(:Leaf, :Internode), within=Subtree(), var=:Rm))), + ModelSpec(ToyCAllocationModel(); name=:plant_allocation, on=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),)), + ModelSpec(ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0); name=:internode_demand, on=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, on=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, on=Many(scale=:Internode)), + ModelSpec(ToyAssimModel(); name=:leaf_assimilation, on=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),)), + ModelSpec(ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0); name=:leaf_demand, on=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, on=Many(scale=:Leaf)), + ModelSpec(ToySoilWaterModel(); name=:soil_water, on=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 + +function benchmark_heavier_scene(model, requests, nsteps) + return run!(model; steps=nsteps, outputs=requests) +end - out = run!(mtg, mapping, meteo_day, tracked_outputs=out_vars, executor=SequentialEx()) -end \ No newline at end of file +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-hard-call-path-benchmark.jl b/benchmark/test-hard-call-path-benchmark.jl new file mode 100644 index 000000000..e4b3eadcf --- /dev/null +++ b/benchmark/test-hard-call-path-benchmark.jl @@ -0,0 +1,161 @@ +using PlantSimEngine + +PlantSimEngine.@process "benchmark_call_source" verbose = false +PlantSimEngine.@process "benchmark_call_controller" verbose = false +PlantSimEngine.@process "benchmark_unrelated_work" verbose = false + +struct BenchmarkCallSourceModel <: AbstractBenchmark_Call_SourceModel end +struct BenchmarkCallControllerModel <: AbstractBenchmark_Call_ControllerModel end +struct BenchmarkUnrelatedWorkModel <: AbstractBenchmark_Unrelated_WorkModel end + +PlantSimEngine.inputs_(::BenchmarkCallSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::BenchmarkCallSourceModel) = (signal=0,) + +function PlantSimEngine.run!( + ::BenchmarkCallSourceModel, + status, + environment, + constants, + context, +) + status.signal += 1 + return nothing +end + +PlantSimEngine.inputs_(::BenchmarkCallControllerModel) = NamedTuple() +PlantSimEngine.outputs_(::BenchmarkCallControllerModel) = (called_signal=0,) + +function PlantSimEngine.run!( + ::BenchmarkCallControllerModel, + status, + environment, + constants, + context, +) + target = only(run_call!(context, :source; publish=false)) + status.called_signal = target.status.signal + return nothing +end + +PlantSimEngine.inputs_(::BenchmarkUnrelatedWorkModel) = NamedTuple() +PlantSimEngine.outputs_(::BenchmarkUnrelatedWorkModel) = (work=0,) + +function PlantSimEngine.run!( + ::BenchmarkUnrelatedWorkModel, + status, + environment, + constants, + context, +) + status.work += 1 + return nothing +end + +function setup_hard_call_path_benchmark(; + nobjects=1000, + usage=:sparse, + steps=100, +) + usage in (:zero, :sparse, :dense) || error( + "Unsupported hard-call benchmark usage `$(usage)`. Use `:zero`, ", + "`:sparse`, or `:dense`.", + ) + objects = Any[PlantSimEngine.Object(:scene; scale=:Scene)] + append!( + objects, + ( + PlantSimEngine.Object( + Symbol(:leaf_, index); + scale=:Leaf, + name=Symbol(:leaf_, index), + parent=:scene, + ) + for index in 1:nobjects + ), + ) + source = ModelSpec( + BenchmarkCallSourceModel(); + name=:source, + on=Many(scale=:Leaf), + ) + unrelated = ModelSpec( + BenchmarkUnrelatedWorkModel(); + name=:unrelated, + on=Many(scale=:Leaf), + ) + applications = if usage == :zero + (source, unrelated) + else + caller_selector = + usage == :sparse ? + One(name=:leaf_1) : + Many(scale=:Leaf) + caller = ModelSpec( + BenchmarkCallControllerModel(); + name=:controller, + on=caller_selector, + calls=( + :source => + One(within=Self(), application=:source), + ), + ) + (source, caller, unrelated) + end + return CompositeModel(objects...; applications=applications), Int(steps) +end + +function benchmark_hard_call_path(model, steps) + return run!(model; steps=steps, outputs=:none) +end + +function hard_call_path_summary(model) + rows = PlantSimEngine.Diagnostics.explain_execution_plan(model) + return ( + no_call_targets=sum(( + row.batch_size for row in rows + if row.call_capability == :no_calls + ); init=0), + call_capable_targets=sum(( + row.batch_size for row in rows + if row.call_capability == :compiled_calls + ); init=0), + unrelated_no_call_targets=sum(( + row.batch_size for row in rows + if row.application_id == :unrelated && + row.call_capability == :no_calls + ); init=0), + ) +end + +function setup_lifecycle_hard_call_benchmark(; + nobjects=1000, + usage=:zero, +) + model, _ = setup_hard_call_path_benchmark(; + nobjects=nobjects, + usage=usage, + steps=1, + ) + simulation = run!( + model; + steps=1, + outputs=:none, + performance=true, + ) + return simulation, nobjects + 1 +end + +function benchmark_lifecycle_event(simulation, new_index) + new_id = Symbol(:leaf_, new_index) + register_object!( + simulation.model, + PlantSimEngine.Object( + new_id; + scale=:Leaf, + name=new_id, + parent=:scene, + ), + ) + continue!(simulation) + return simulation +end diff --git a/benchmark/test-multirate-buffer-benchmark.jl b/benchmark/test-multirate-buffer-benchmark.jl index 2bc67d5a5..ac35ead70 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 @@ -9,90 +7,83 @@ struct MRBenchSourceModel <: AbstractMrbenchsourceModel end PlantSimEngine.inputs_(::MRBenchSourceModel) = NamedTuple() PlantSimEngine.outputs_(::MRBenchSourceModel) = (X=-Inf,) -function PlantSimEngine.run!(m::MRBenchSourceModel, models, status, meteo, constants=nothing, extra=nothing) +function PlantSimEngine.run!(m::MRBenchSourceModel, status, environment, constants=nothing, context=nothing) m.n[] += 1 status.X = float(m.n[]) end PlantSimEngine.@process "mrbenchconsumer4" verbose = false struct MRBenchConsumer4Model <: AbstractMrbenchconsumer4Model end -PlantSimEngine.inputs_(::MRBenchConsumer4Model) = (X=[-Inf],) +PlantSimEngine.inputs_(::MRBenchConsumer4Model) = (X=Required(Vector{Float64}),) PlantSimEngine.outputs_(::MRBenchConsumer4Model) = (Y4=-Inf,) -function PlantSimEngine.run!(::MRBenchConsumer4Model, models, status, meteo, constants=nothing, extra=nothing) +function PlantSimEngine.run!(::MRBenchConsumer4Model, status, environment, constants=nothing, context=nothing) status.Y4 = sum(status.X) end PlantSimEngine.@process "mrbenchconsumer24" verbose = false struct MRBenchConsumer24Model <: AbstractMrbenchconsumer24Model end -PlantSimEngine.inputs_(::MRBenchConsumer24Model) = (X=[-Inf],) +PlantSimEngine.inputs_(::MRBenchConsumer24Model) = (X=Required(Vector{Float64}),) PlantSimEngine.outputs_(::MRBenchConsumer24Model) = (Y24=-Inf,) -function PlantSimEngine.run!(::MRBenchConsumer24Model, models, status, meteo, constants=nothing, extra=nothing) +function PlantSimEngine.run!(::MRBenchConsumer24Model, status, environment, constants=nothing, context=nothing) 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 = PlantSimEngine.Object[ + PlantSimEngine.Object(:plant; scale=:Plant), + ] + append!( + objects, + [ + PlantSimEngine.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, on=Many(scale=:Leaf), every=Hour(1)), + ModelSpec(MRBenchConsumer4Model(); name=:four_hour_consumer, on=One(scale=:Plant), inputs=(:X => Many( + scale=:Leaf, + within=Subtree(), + application=:hourly_source, + var=:X, + policy=Integrate(), + window=Hour(4), + )), every=Hour(4)), + ModelSpec(MRBenchConsumer24Model(); name=:daily_consumer, on=One(scale=:Plant), inputs=(:X => Many( + scale=:Leaf, + within=Subtree(), + application=:hourly_source, + var=:X, + policy=Integrate(), + window=Day(1), + )), every=Day(1)), ) nsteps = 24 * ndays - meteo = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65)], nsteps)) + environment = [(T=20.0, Wind=1.0, Rh=0.65, duration=Hour(1)) for _ in 1:nsteps] + model = CompositeModel(objects...; applications=applications, environment=environment) 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)), ] + return model, reqs, nsteps +end - tracked = Dict(:Plant => (:Y4, :Y24), :Leaf => (:X,)) - return mtg, mapping, meteo, reqs, tracked, nsteps +function benchmark_multirate_retain_all_run(model, nsteps) + return run!(model; steps=nsteps, outputs=:all) 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_output_request_run(model, reqs, nsteps) + return run!(model; steps=nsteps, outputs=reqs) 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_no_output_run(model, nsteps) + return run!(model; steps=nsteps, outputs=:none) 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..e06317c2a 100644 --- a/benchmark/test-xpalm.jl +++ b/benchmark/test-xpalm.jl @@ -1,60 +1,235 @@ -#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_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) +const XPALM_REFERENCE_BENCHMARK_VARIABLES = Dict{Symbol,Any}( + :Scene => (:lai, :leaf_area, :aPPFD), + :Plant => ( + :plant_age, + :leaf_area, + :aPPFD, + :Rm, + :carbon_assimilation, + :phytomer_count, + :biomass_bunch_harvested, + :biomass_bunch_harvested_cum, + :n_bunches_harvested, + :n_bunches_harvested_cum, + :biomass_oil_harvested, + :biomass_oil_harvested_cum, + ), + :Soil => (:ftsw, :root_depth), +) + +const XPALM_SMALL_BENCHMARK_VARIABLES = Dict{Symbol,Any}( + :Scene => (:lai,), +) + +function _xpalm_output_requests(model, vars) + applications = PlantSimEngine.Diagnostics.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 || + PlantSimEngine.Diagnostics.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_benchmark_meteo(; nsteps=nothing) + meteo = CSV.read( + joinpath(dirname(dirname(pathof(XPalm))), "0-data", "meteo.csv"), + DataFrame, + ) + :duration in propertynames(meteo) || + (meteo.duration = fill(Day(1), nrow(meteo))) + isnothing(nsteps) && return meteo + requested_steps = Int(nsteps) + 1 <= requested_steps <= nrow(meteo) || error( + "XPalm benchmark `nsteps` must be between 1 and $(nrow(meteo)), got $(requested_steps).", + ) + return meteo[1:requested_steps, :] +end - out_vars = Dict{Symbol,Any}( +function xpalm_reference_model_create(; nsteps=nothing) + meteo = _xpalm_benchmark_meteo(; nsteps=nsteps) + palm = XPalm.Palm( + initiation_age=0, + parameters=XPalm.default_parameters(), + ) + model = XPalm.xpalm_scene(palm; environment=meteo) + return model, nrow(meteo) +end + +function _xpalm_benchmark_scene(vars; nsteps=nothing) + model, resolved_steps = xpalm_reference_model_create(; nsteps=nsteps) + return model, _xpalm_output_requests(model, vars), resolved_steps +end + +function xpalm_default_param_create(; nsteps=nothing) + 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), ) + return _xpalm_benchmark_scene(vars; nsteps=nsteps) +end - # 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 +function xpalm_small_param_create(; nsteps=nothing) + return _xpalm_benchmark_scene( + XPALM_SMALL_BENCHMARK_VARIABLES; + nsteps=nsteps, + ) 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_reference_param_create(; nsteps=nothing) + return _xpalm_benchmark_scene( + XPALM_REFERENCE_BENCHMARK_VARIABLES; + nsteps=nsteps, + ) end -function xpalm_default_param_convert_outputs(sim_outputs) - df = PlantSimEngine.convert_outputs(sim_outputs, DataFrame, no_value=missing) - return df +function xpalm_reference_end_to_end(; nsteps=nothing) + meteo = _xpalm_benchmark_meteo(; nsteps=nsteps) + return XPalm.xpalm( + meteo, + DataFrame; + vars=XPALM_REFERENCE_BENCHMARK_VARIABLES, + architecture=false, + palm=XPalm.Palm( + initiation_age=0, + parameters=XPalm.default_parameters(), + ), + ) end +function xpalm_default_param_run( + model, + requests, + nsteps; + outputs=requests, + performance=false, +) + return PlantSimEngine.run!( + model; + steps=nsteps, + outputs=outputs, + performance=performance, + ) +end + +function xpalm_reference_final_phytomer_count(simulation) + plant = only(PlantSimEngine.model_objects(simulation.model; scale=:Plant)) + return plant.status.phytomer_count +end + +function xpalm_reference_final_state(simulation) + plant = only(PlantSimEngine.model_objects(simulation.model; scale=:Plant)) + scene = only(PlantSimEngine.model_objects(simulation.model; scale=:Scene)) + soil = only(PlantSimEngine.model_objects(simulation.model; scale=:Soil)) + return ( + current_step=PlantSimEngine.current_step(simulation), + phytomer_count=plant.status.phytomer_count, + lai=scene.status.lai, + ftsw=soil.status.ftsw, + ) +end + +function xpalm_reference_param_run( + model, + requests, + nsteps; + outputs=requests, + performance=false, +) + return xpalm_default_param_run( + model, + requests, + nsteps; + outputs=outputs, + performance=performance, + ) +end -println(Pkg.status("XPalm")) +function xpalm_reference_full_cycle_expected_state() + return ( + current_step=4160, + phytomer_count=344, + lai=5.0587602356164405, + ftsw=0.7991179101191216, + ) +end -#=@testset "XPalm simple test" begin - # default number of seconds is 5 - b_XP = @benchmark xpalm_default_param_run() seconds = 120 +function xpalm_reference_state_matches( + state, + expected=xpalm_reference_full_cycle_expected_state(); + atol=1.0e-8, + rtol=1.0e-8, +) + return state.current_step == expected.current_step && + state.phytomer_count == expected.phytomer_count && + isapprox(state.lai, expected.lai; atol=atol, rtol=rtol) && + isapprox(state.ftsw, expected.ftsw; atol=atol, rtol=rtol) +end - #N = length(b_XP.times) +function xpalm_reference_high_level_final_state(outputs) + plant = outputs[:Plant] + scene = outputs[:Scene] + soil = outputs[:Soil] + return ( + current_step=nrow(plant), + phytomer_count=last(plant.phytomer_count), + lai=last(scene.lai), + ftsw=last(soil.ftsw), + ) +end - @test mean(b_XP.times*1e-9) > 10 - @test mean(b_XP.times*1e-9) < 15 -end =# \ No newline at end of file +function xpalm_reference_high_level_state_matches( + outputs; + expected=xpalm_reference_full_cycle_expected_state(), + atol=1.0e-8, + rtol=1.0e-8, +) + return xpalm_reference_state_matches( + xpalm_reference_high_level_final_state(outputs), + expected; + atol=atol, + rtol=rtol, + ) +end + +function xpalm_default_param_collect_outputs(simulation) + return PlantSimEngine.collect_outputs(simulation; sink=DataFrame) +end diff --git a/benchmark/test/runtests.jl b/benchmark/test/runtests.jl new file mode 100644 index 000000000..a1b34b857 --- /dev/null +++ b/benchmark/test/runtests.jl @@ -0,0 +1,675 @@ +using BenchmarkTools +using CSV +using DataFrames +using Dates +using MultiScaleTreeGraph +using PlantMeteo +using PlantSimEngine +using PlantSimEngine.Examples +using Profile +using Statistics +using Test +using TOML + +const BENCHMARK_TEST_PATTERN = + isempty(ARGS) ? nothing : Regex(only(ARGS), "i") +benchmark_test_enabled(name) = + isnothing(BENCHMARK_TEST_PATTERN) || + occursin(BENCHMARK_TEST_PATTERN, name) + +if benchmark_test_enabled("full-performance project bootstrap smoke") + @testset "full-performance project bootstrap smoke" begin + include( + joinpath( + @__DIR__, + "..", + "prepare_full_performance_project.jl", + ), + ) + mktempdir() do directory + project_path = joinpath(directory, "Project.toml") + cp( + joinpath(@__DIR__, "..", "Project.toml"), + project_path, + ) + prepare_full_performance_project!(project_path) + project = TOML.parsefile(project_path) + @test !haskey(project, "sources") + @test haskey(project["deps"], "PlantSimEngine") + @test haskey(project["deps"], "XPalm") + @test haskey(project["deps"], "PlantBiophysics") + end + end +end + +if benchmark_test_enabled("PlantSimEngine benchmark API smoke") + @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 +end + +if benchmark_test_enabled("multirate benchmark API smoke") + @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)) + no_output_model, _, no_output_steps = + setup_multirate_buffer_benchmark(; ndays=1, nleaves=4) + no_output_simulation = + benchmark_multirate_no_output_run( + no_output_model, + no_output_steps, + ) + @test current_step(no_output_simulation) == no_output_steps + retention = + PlantSimEngine.Diagnostics.explain_output_retention( + no_output_simulation, + ) + @test all( + row.reasons == (:temporal_dependency,) + for row in retention + ) + @test maximum( + length, + values(outputs(no_output_simulation)), + ) <= 25 + end +end + +if benchmark_test_enabled("lifecycle benchmark API smoke") + @testset "lifecycle benchmark API smoke" begin + isdefined(@__MODULE__, :BenchmarkCallSourceModel) || + include( + joinpath( + @__DIR__, + "..", + "test-hard-call-path-benchmark.jl", + ), + ) + for (nobjects, usage) in ( + (8, :zero), + (64, :zero), + (16, :dense), + ) + simulation, new_index = + setup_lifecycle_hard_call_benchmark(; + nobjects=nobjects, + usage=usage, + ) + benchmark_lifecycle_event(simulation, new_index) + new_id = Symbol(:leaf_, new_index) + new_object = only( + object for object in model_objects( + simulation.model; + scale=:Leaf, + ) + if object.id == ObjectId(new_id) + ) + @test current_step(simulation) == 2 + @test new_object.status.work == 1 + @test new_object.status.signal == 1 + if usage == :dense + @test new_object.status.called_signal == 1 + end + performance = + PlantSimEngine.Advanced.runtime_performance(simulation) + @test performance.counts[ + :execution_target_rebuild_new + ] <= 3 + end + end +end + +if benchmark_test_enabled("hard-call path benchmark API smoke") + @testset "hard-call path benchmark API smoke" begin + include(joinpath(@__DIR__, "..", "test-hard-call-path-benchmark.jl")) + expected_call_targets = Dict( + :zero => 0, + :sparse => 1, + :dense => 16, + ) + for usage in (:zero, :sparse, :dense) + model, steps = setup_hard_call_path_benchmark(; + nobjects=16, + usage=usage, + steps=2, + ) + summary = hard_call_path_summary(model) + @test summary.call_capable_targets == + expected_call_targets[usage] + @test summary.unrelated_no_call_targets == 16 + simulation = benchmark_hard_call_path(model, steps) + @test current_step(simulation) == steps + @test all( + object.status.work == steps + for object in model_objects(model; scale=:Leaf) + ) + end + end +end + +if benchmark_test_enabled("internal-only benchmark suite assembly smoke") + @testset "internal-only benchmark suite assembly smoke" begin + benchmark_module = Module(:InternalOnlyBenchmarkSuite) + Core.eval( + benchmark_module, + :(include(path) = Base.include(@__MODULE__, path)), + ) + Core.eval(benchmark_module, :(const Object = Nothing)) + include_error = try + withenv( + "GITHUB_ACTIONS" => "true", + "PSE_BENCHMARK_INCLUDE_DOWNSTREAM" => nothing, + ) do + Base.include( + benchmark_module, + joinpath(@__DIR__, "..", "benchmarks.jl"), + ) + end + nothing + catch error + sprint(showerror, error, catch_backtrace()) + end + @test isnothing(include_error) + if isnothing(include_error) + suite = getfield( + benchmark_module, + :SUITE, + )[getfield(benchmark_module, :suite_name)] + @test haskey(suite, "PSE") + @test haskey(suite, "PSE_hard_calls_zero") + @test haskey(suite, "PSE_lifecycle_large") + @test !haskey(suite, "PBP") + @test !haskey(suite, "PBP_batch_run") + @test !haskey(suite, "XPalm_run_100") + @test !haskey(suite, "XPalm_all_outputs_100") + end + end +end + +if benchmark_test_enabled("PlantBiophysics benchmark API smoke") + @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 +end + +if benchmark_test_enabled("XPalm benchmark API smoke") + @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 +end + +if benchmark_test_enabled("XPalm staged performance profile smoke") + @testset "XPalm staged performance profile smoke" begin + include(joinpath(@__DIR__, "..", "performance_regression.jl")) + metadata = _performance_metadata(; warmup_policy="metadata smoke") + @test length(metadata.manifest_hash) == 64 + @test length(metadata.fixture_hash) == 64 + @test !isempty(metadata.hostname) + result = run_xpalm_performance_profile(; profile=:smoke) + @test result.no_output_state == result.reference_state + @test result.small_state == result.reference_state + @test result.all_output_state == result.reference_state + @test result.reference_state.current_step == PERFORMANCE_SMOKE_STEPS + @test any( + row -> + row.stage == "simulation_reference_outputs" && + row.metric == "steps_executed" && + row.value == PERFORMANCE_SMOKE_STEPS, + result.records, + ) + @test any( + row -> + row.stage == "initial_scene_compilation" && + row.metric == "median_time", + result.records, + ) + @test any( + row -> + row.stage == "clean_steady_state_step" && + row.metric == "median_time", + result.records, + ) + @test any( + row -> + row.stage == "simulation_small_outputs" && + row.metric == "median_time", + result.records, + ) + @test any( + row -> + row.stage == "simulation_all_outputs" && + row.metric == "median_time", + result.records, + ) + @test any( + row -> + row.stage == "collect_reference_outputs" && + row.metric == "wall_time", + result.records, + ) + @test any( + row -> + row.stage == "simulation_reference_outputs" && + row.metric == "median_time", + result.records, + ) + @test any( + row -> + row.stage == "simulation_reference_outputs" && + row.metric == "minimum_allocations", + result.records, + ) + @test only( + row.value for row in result.records + if row.stage == "simulation_reference_outputs" && + row.metric == "samples" + ) == PERFORMANCE_STATISTICAL_SAMPLES + trial = BenchmarkTools.@benchmark 1 + 1 samples = 2 evals = 1 + group = BenchmarkTools.BenchmarkGroup() + group["tiny"] = trial + summary = _benchmark_summary_records(group, metadata) + @test length(summary) == 1 + @test only(summary).benchmark == "tiny" + @test only(summary).samples == 2 + @test only(summary).minimum_time_ns <= only(summary).median_time_ns + @test only(summary).median_allocations == 0 + end +end + +if !isnothing(BENCHMARK_TEST_PATTERN) && + benchmark_test_enabled("benchmark suite assembly smoke") + @testset "benchmark suite assembly smoke" begin + include(joinpath(@__DIR__, "..", "benchmarks.jl")) + suite = SUITE[suite_name] + @test haskey(suite, "PSE_hard_calls_zero") + @test haskey(suite, "PSE_hard_calls_sparse") + @test haskey(suite, "PSE_hard_calls_dense") + @test haskey(suite, "PSE_lifecycle_small") + @test haskey(suite, "PSE_lifecycle_large") + @test haskey( + suite, + "PSE_lifecycle_immediate_hard_call", + ) + @test haskey(suite, "PSE_multirate_no_output_run") + @test haskey(suite, "XPalm_small_outputs_100") + @test haskey(suite, "XPalm_all_outputs_100") + end +end + +if !isnothing(BENCHMARK_TEST_PATTERN) && + benchmark_test_enabled("XPalm staged performance profile short") + @testset "XPalm staged performance profile short" begin + include(joinpath(@__DIR__, "..", "performance_regression.jl")) + output_path = joinpath( + @__DIR__, + "..", + "results", + "xpalm-short-latest.csv", + ) + result = write_xpalm_performance_profile(output_path; profile=:short) + @test result.no_output_state == result.reference_state + @test result.reference_state.current_step == PERFORMANCE_SHORT_STEPS + @test isfile(output_path) + @test any( + row -> + row.stage == "simulation_no_outputs" && + row.metric == "execution_targets_visited", + result.records, + ) + end +end + +if !isnothing(BENCHMARK_TEST_PATTERN) && + benchmark_test_enabled("XPalm staged performance profile medium") + @testset "XPalm staged performance profile medium" begin + include(joinpath(@__DIR__, "..", "performance_regression.jl")) + output_path = joinpath( + @__DIR__, + "..", + "results", + "xpalm-medium-latest.csv", + ) + result = write_xpalm_performance_profile(output_path; profile=:medium) + @test result.no_output_state == result.reference_state + @test result.reference_state.current_step == PERFORMANCE_MEDIUM_STEPS + @test isfile(output_path) + @test any( + row -> + row.stage == "simulation_no_outputs" && + row.metric == "output_retention_reuses", + result.records, + ) + end +end + +if !isnothing(BENCHMARK_TEST_PATTERN) && + benchmark_test_enabled("XPalm staged performance profile full") + @testset "XPalm staged performance profile full" begin + include(joinpath(@__DIR__, "..", "performance_regression.jl")) + output_path = joinpath( + @__DIR__, + "..", + "results", + "xpalm-full-latest.csv", + ) + result = write_xpalm_performance_profile(output_path; profile=:full) + @test result.no_output_state == result.reference_state + @test xpalm_reference_state_matches(result.reference_state) + @test isfile(output_path) + @test any( + row -> + row.stage == "historical_end_to_end_reference" && + row.metric == "wall_time", + result.records, + ) + end +end + +if !isnothing(BENCHMARK_TEST_PATTERN) && + benchmark_test_enabled("XPalm full no-output performance") + @testset "XPalm full no-output performance" begin + include(joinpath(@__DIR__, "..", "performance_regression.jl")) + _warmup_xpalm_performance!(PERFORMANCE_FULL_STEPS) + metadata = _performance_metadata(; + warmup_policy="unmeasured full-profile standard warmup", + ) + records = NamedTuple[] + output_path = joinpath( + @__DIR__, + "..", + "results", + "xpalm-full-no-output-latest.csv", + ) + model, nsteps = _measure_performance_stage!( + records, + metadata, + :full, + :scene_construction_no_outputs, + output_path, + ) do + xpalm_reference_model_create(; nsteps=PERFORMANCE_FULL_STEPS) + end + simulation = _measure_performance_stage!( + records, + metadata, + :full, + :simulation_no_outputs, + output_path, + ) do + xpalm_reference_param_run( + model, + OutputRequest[], + nsteps; + outputs=:none, + ) + end + final_state = xpalm_reference_final_state(simulation) + _record_xpalm_state!( + records, + metadata, + :full, + :final_state_no_outputs, + final_state, + ) + _checkpoint_performance_records(output_path, records) + @test final_state.current_step == PERFORMANCE_FULL_STEPS + @test final_state.phytomer_count == 344 + @test isfile(output_path) + end +end + +if !isnothing(BENCHMARK_TEST_PATTERN) && + benchmark_test_enabled("XPalm full warmed no-output performance") + @testset "XPalm full warmed no-output performance" begin + include(joinpath(@__DIR__, "..", "performance_regression.jl")) + warmup_model, warmup_steps = + xpalm_reference_model_create(; nsteps=PERFORMANCE_FULL_STEPS) + xpalm_reference_param_run( + warmup_model, + OutputRequest[], + warmup_steps; + outputs=:none, + ) + model, nsteps = + xpalm_reference_model_create(; nsteps=PERFORMANCE_FULL_STEPS) + metadata = _performance_metadata(; + warmup_policy="unmeasured complete 4,160-day lifecycle run", + ) + records = NamedTuple[] + output_path = joinpath( + @__DIR__, + "..", + "results", + "xpalm-full-warmed-no-output-latest.csv", + ) + simulation = _measure_performance_stage!( + records, + metadata, + :full, + :simulation_warmed_no_outputs, + output_path, + ) do + xpalm_reference_param_run( + model, + OutputRequest[], + nsteps; + outputs=:none, + ) + end + final_state = xpalm_reference_final_state(simulation) + _record_xpalm_state!( + records, + metadata, + :full, + :final_state_warmed_no_outputs, + final_state, + ) + _checkpoint_performance_records(output_path, records) + wall_time = only( + row.value for row in records + if row.stage == "simulation_warmed_no_outputs" && + row.metric == "wall_time" + ) + @test final_state.current_step == PERFORMANCE_FULL_STEPS + @test final_state.phytomer_count == 344 + @test xpalm_reference_state_matches(final_state) + @test wall_time <= 20.0 + @test isfile(output_path) + end +end + +if !isnothing(BENCHMARK_TEST_PATTERN) && + benchmark_test_enabled("XPalm full steady tail performance") + @testset "XPalm full steady tail performance" begin + include(joinpath(@__DIR__, "..", "performance_regression.jl")) + _warmup_xpalm_performance!(PERFORMANCE_SHORT_STEPS) + model, nsteps = + xpalm_reference_model_create(; nsteps=PERFORMANCE_FULL_STEPS) + tail_steps = 660 + simulation = xpalm_reference_param_run( + model, + OutputRequest[], + nsteps - tail_steps; + outputs=:none, + ) + GC.gc() + measurement = @timed PlantSimEngine.continue!( + simulation; + steps=tail_steps, + ) + output_path = joinpath( + @__DIR__, + "..", + "results", + "xpalm-full-steady-tail-latest.csv", + ) + CSV.write( + output_path, + DataFrame( + metric=["wall_time", "allocated", "gc_time"], + value=[ + measurement.time, + measurement.bytes, + measurement.gctime, + ], + ), + ) + @test current_step(simulation) == PERFORMANCE_FULL_STEPS + @test isfile(output_path) + end +end + +if !isnothing(BENCHMARK_TEST_PATTERN) && + benchmark_test_enabled("XPalm allocation profile short") + @testset "XPalm allocation profile short" begin + include(joinpath(@__DIR__, "..", "performance_regression.jl")) + _warmup_xpalm_performance!(PERFORMANCE_SHORT_STEPS) + model, nsteps = + xpalm_reference_model_create(; nsteps=PERFORMANCE_SHORT_STEPS) + Profile.Allocs.clear() + simulation = Profile.Allocs.@profile sample_rate = 0.01 xpalm_reference_param_run( + model, + OutputRequest[], + nsteps; + outputs=:none, + ) + allocation_results = Profile.Allocs.fetch() + pse_root = dirname(dirname(@__DIR__)) + xpalm_root = dirname(dirname(pathof(XPalm))) + totals = Dict{ + Tuple{String,String,Int,String,String}, + Tuple{Int,Int}, + }() + for allocation in allocation_results.allocs + frame_index = findfirst(allocation.stacktrace) do frame + file = string(frame.file) + occursin(pse_root, file) || occursin(xpalm_root, file) + end + isnothing(frame_index) && continue + frame = allocation.stacktrace[frame_index] + file = string(frame.file) + source = occursin(pse_root, file) ? "PlantSimEngine" : "XPalm" + key = ( + source, + file, + frame.line, + string(frame.func), + string(allocation.type), + ) + count, bytes = get(totals, key, (0, 0)) + totals[key] = (count + 1, bytes + allocation.size) + end + rows = [ + ( + source=first(key), + file=key[2], + line=key[3], + function_name=key[4], + allocation_type=key[5], + sampled_allocations=first(value), + sampled_bytes=last(value), + sample_rate=0.01, + ) + for (key, value) in totals + ] + sort!(rows; by=row -> row.sampled_bytes, rev=true) + output_path = joinpath( + @__DIR__, + "..", + "results", + "xpalm-allocations-short-latest.csv", + ) + CSV.write(output_path, DataFrame(rows)) + @test current_step(simulation) == PERFORMANCE_SHORT_STEPS + @test !isempty(rows) + @test isfile(output_path) + end +end + +if !isnothing(BENCHMARK_TEST_PATTERN) && + benchmark_test_enabled("XPalm CPU profile medium") + @testset "XPalm CPU profile medium" begin + include(joinpath(@__DIR__, "..", "performance_regression.jl")) + _warmup_xpalm_performance!(PERFORMANCE_MEDIUM_STEPS) + model, nsteps = + xpalm_reference_model_create(; nsteps=PERFORMANCE_MEDIUM_STEPS) + Profile.clear() + simulation = Profile.@profile xpalm_reference_param_run( + model, + OutputRequest[], + nsteps; + outputs=:none, + ) + output_path = joinpath( + @__DIR__, + "..", + "results", + "xpalm-cpu-medium-latest.txt", + ) + open(output_path, "w") do io + Profile.print( + io; + format=:flat, + sortedby=:count, + C=false, + combine=true, + ) + end + @test current_step(simulation) == PERFORMANCE_MEDIUM_STEPS + @test isfile(output_path) + end +end + +if !isnothing(BENCHMARK_TEST_PATTERN) && + benchmark_test_enabled("XPalm CPU profile full") + @testset "XPalm CPU profile full" begin + include(joinpath(@__DIR__, "..", "performance_regression.jl")) + _warmup_xpalm_performance!(PERFORMANCE_FULL_STEPS) + model, nsteps = + xpalm_reference_model_create(; nsteps=PERFORMANCE_FULL_STEPS) + profiled_steps = 660 + simulation = xpalm_reference_param_run( + model, + OutputRequest[], + nsteps - profiled_steps; + outputs=:none, + ) + Profile.clear() + Profile.@profile PlantSimEngine.continue!( + simulation; + steps=profiled_steps, + ) + output_path = joinpath( + @__DIR__, + "..", + "results", + "xpalm-cpu-full-latest.txt", + ) + open(output_path, "w") do io + Profile.print( + io; + format=:flat, + sortedby=:count, + C=false, + combine=true, + ) + end + @test current_step(simulation) == PERFORMANCE_FULL_STEPS + @test isfile(output_path) + end +end diff --git a/docs/make.jl b/docs/make.jl index 7c3dd6a01..9d3640e97 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, + ) + GraphEditor.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) @@ -21,70 +42,93 @@ makedocs(; size_threshold=700000 ), pages=[ "Home" => "index.md", - "Introduction" => [ + "Start here" => [ "Why PlantSimEngine ?" => "./introduction/why_plantsimengine.md", - "Why Julia ?" => "./introduction/why_julia.md", + "Mental model" => "./journeys/users/mental_model.md", + "One object over time" => "./journeys/users/one_object.md", + "Several same-scale objects" => "./journeys/users/several_objects.md", ], - "Prerequisites" => [ - "Installing and running PlantSimEngine" => "./prerequisites/installing_plantsimengine.md", - "Key Concepts" => "./prerequisites/key_concepts.md", - "Julia language basics" => "./prerequisites/julia_basics.md", + "Structure and composition" => [ + "One multiscale plant" => "./journeys/users/one_plant.md", + "Several plants" => "./journeys/users/several_plants.md", + "Value coupling" => "./guides/multiscale/value_coupling.md", + "Importing an MTG" => "./guides/multiscale/import_mtg.md", + "How composite models execute" => "./guides/multiscale/concepts.md", + "Visualizing structure" => "./guides/multiscale/visualizing_structure.md", ], - "Step by step - Single-scale simulations" => [ - "Detailed first simulation" => "./step_by_step/detailed_first_example.md", - "Coupling" => "./step_by_step/simple_model_coupling.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", + "Environment and time" => [ + "Read an environment" => "./journeys/users/environments.md", + "Different model cadences" => "./journeys/users/cadences.md", + "Hourly, daily, and weekly" => "./guides/time/hourly_daily_weekly.md", + "Advanced configuration" => "./guides/time/advanced_time_environment.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", + "Dynamic and advanced simulations" => [ + "Modify plant structure" => "./journeys/users/structure_changes.md", + "Modify the environment" => "./journeys/users/mutable_environments.md", + "Control advanced execution" => "./journeys/users/advanced_execution.md", + "MAESPA-style synthesis" => "./journeys/users/maespa_synthesis.md", + "Part 2: roots and water" => "./tutorials/growing_plant/part2_roots_water.md", + "Part 3: debugging" => "./tutorials/growing_plant/part3_debugging.md", + "Manual calls" => "./guides/multiscale/manual_calls.md", + "Advanced coupling and hard dependencies" => "./step_by_step/advanced_coupling.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", + "Implement models" => [ + "Basic contract and reuse" => "./journeys/modelers/basic_model.md", + "Cross-object values" => "./journeys/modelers/cross_object_values.md", + "Environment and cadence traits" => "./journeys/modelers/environment_and_cadence.md", + "Hard dependencies" => "./journeys/modelers/hard_dependencies.md", + "Mutable environment controllers" => "./journeys/modelers/mutable_environment.md", + "Port an existing model" => "./guides/modelers/port_existing_model.md", + "Implement a process" => "./step_by_step/implement_a_process.md", + "Implement a model" => "./step_by_step/implement_a_model.md", + "Composition and switching" => "./step_by_step/model_switching.md", + "Stateful models" => "./guides/modelers/stateful_models.md", + "Additional notes" => "./step_by_step/implement_a_model_additional.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" => [ + "Reference" => [ + "Installing PlantSimEngine" => "./prerequisites/installing_plantsimengine.md", + "Julia language basics" => "./prerequisites/julia_basics.md", + "Why Julia ?" => "./introduction/why_julia.md", + "Model execution" => "model_execution.md", + "Model traits" => "model_traits.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", + "Graph editor" => "./guides/graph_visualizer_editor.md", + "Common errors" => "./troubleshooting/common_errors.md", + "Runtime contracts" => "./troubleshooting/runtime_contracts.md", + "Dependency cycles" => "./troubleshooting/dependency_cycles.md", + "Downstream testing" => "./troubleshooting_and_testing/downstream_tests.md", + "Environment backend extensions" => "./guides/extensions/environment_backends.md", + "AI agent skill" => "agent_skill.md", "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",], - "Developer guidelines" => "developers.md", - "Roadmap" => "planned_features.md", + ], + "Migration" => [ + "From the mapping runtime" => "migration_composite_model.md", + ], + "Maintainers" => [ + "Developer guidelines" => "developers.md", + "Internal API" => "./API/API_private.md", + "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", + "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..788450316 100644 --- a/docs/src/API/API_public.md +++ b/docs/src/API/API_public.md @@ -1,165 +1,188 @@ # 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=..., on=..., inputs=..., calls=..., every=..., + environment=..., output_routing=..., updates=...)` is the one application + construction form. + +### Coupling + +- `ModelSpec(...; inputs=...)` declares value dependencies. +- `ModelSpec(...; 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!(context, :name; publish=false)` executes every resolved hard-call + target and always returns a vector-like `CallTargets` collection. +- `call_targets(context, :name)` returns the same non-executing collection for + fine-grained execution with `run_call!(target; ...)`. + +### Model input schema + +- `Required(T)` declares an input that object state or another application must + supply. `T` is an expected type and may be generic. +- `Default(value)` declares a true model fallback that needs no user + initialization. +- `inputs_(model)` uses only these explicit declarations; plain literals are + rejected. +- `outputs_(model)` literals remain initial output-state values. +- `init_variables(model)` returns only genuine input defaults and initial + output values. + +### Selectors + +- Multiplicity: `One(...)`, `OptionalOne(...)`, and `Many(...)`. +- Scope: `SceneScope()`, `Self()`, `Subtree()`, `SelfPlant()`, + `Ancestor(...)`, and `Scope(name)`. +- Label criteria: `kind=...`, `species=...`, `scale=...`, and `name=...`. +- Topology relations: `Relation(...)`. + +`Self()` always means the current object: the object on which the consuming +application runs. It means a plant only when that object is itself the plant. + +Selector fields are checked where the selector is used: + +| Context | Accepted criteria | +|---|---| +| `ModelSpec(...; on=...)` | `kind`, `species`, `scale`, `name`, and a scene or named scope | +| `ModelSpec(...; inputs=...)` | object criteria plus `process`, `application`, `var`, `policy`, `window`, `from_status`, and `after` | +| `ModelSpec(...; calls=...)` | object criteria plus `process` and `application` | +| object queries and `OutputRequest` selectors | object criteria only | + +Unsupported or misspelled fields fail when the selector is constructed. +Object-relative scopes and relations require a current object, so they belong +in inputs, calls, or contextual object/output queries rather than application +targets. + +### Time and environment + +- `ModelSpec(...; every=period)` sets an application cadence. +- `HoldLast`, `Interpolate`, `Integrate`, and `Aggregate` define temporal + input policies. +- `Environment(...)` configures environment providers and source remapping. +- Models declare sampled environment variables with `environment_inputs_`. +- Mutable environment controllers pass trial state with + `run_call!(context, name; environment=trial_state)` and commit accepted state + with `commit_environment!`. +- `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(context)` 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. +- `final_state(simulation)` returns a latest-state snapshot without + requiring output retention; pass an object id or selector for multi-object + simulations. +- `collect_outputs(sim)` materializes retained output streams. + +### Explanations + +Use the `Diagnostics` namespace instead of inspecting internals: + +- `Diagnostics.explain_objects` +- `Diagnostics.explain_instances` +- `Diagnostics.explain_scopes` +- `Diagnostics.explain_applications` +- `Diagnostics.explain_bindings` +- `Diagnostics.explain_calls` +- `Diagnostics.explain_environment_bindings` +- `Diagnostics.explain_schedule` +- `Diagnostics.explain_writers` +- `Diagnostics.explain_execution_plan` +- `Diagnostics.explain_output_retention` +- `Diagnostics.explain_outputs` +- `Diagnostics.explain_initialization` +- `Diagnostics.input_carrier`, `Diagnostics.input_value`, and + `Diagnostics.has_reference_carrier` +- `Diagnostics.object_address` + +See [Migrating To The CompositeModel/Object API](../migration_composite_model.md) for +translations from removed APIs. + +### CompositeModel graph visualization and editing + +- `GraphEditor.compile_model_report(model; strict=false)` preserves partial graph state and + structured diagnostics for incomplete or cyclic composite models. +- `GraphEditor.model_graph_view(model; level=:applications)` returns the typed graph view. +- `GraphEditor.model_graph_view_json(model)` serializes the same DTO used by the browser. +- `GraphEditor.write_model_graph_view(path, model)` writes a self-contained static viewer. +- `GraphEditor.edit_graph(model)` starts the optional HTTP editor after `using HTTP`. +- `GraphEditor.current_model(session)`, `GraphEditor.undo!(session)`, + `GraphEditor.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. + +### Environment backend extensions + +Backend packages extend the protocol under `EnvironmentAPI`, including +`EnvironmentAPI.AbstractEnvironmentBackend`, +`EnvironmentAPI.bind_environment`, `EnvironmentAPI.sample`, +`EnvironmentAPI.commit_environment!`, and `EnvironmentAPI.update_index!`. +The root-level `commit_environment!` remains part of the ordinary model-kernel +workflow for committing an accepted controller state. + +### Fitting and evaluation + +Generic fitting and metrics live under `Evaluation`: `Evaluation.fit`, +`Evaluation.RMSE`, `Evaluation.NRMSE`, `Evaluation.EF`, and `Evaluation.dr`. +PlantMeteo reducers are accessed from `PlantMeteo` directly rather than being +re-exported by PlantSimEngine. + +## 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 `Diagnostics.explain_*`, which accepts 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] +Modules = [ + PlantSimEngine, + PlantSimEngine.Diagnostics, + PlantSimEngine.GraphEditor, + PlantSimEngine.EnvironmentAPI, + PlantSimEngine.Evaluation, +] 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..bc516b306 --- /dev/null +++ b/docs/src/API/public_symbols.md @@ -0,0 +1,138 @@ +# Public Symbol Inventory + +This page records the supported default namespace and the four focused public +submodules. Compiler representations and cache controls are intentionally +listed separately under [`PlantSimEngine.Advanced`](#advanced-namespace). + +`using PlantSimEngine` imports the ordinary model-author and simulation-user +workflow plus the `Diagnostics`, `GraphEditor`, `EnvironmentAPI`, and +`Evaluation` module names. Their members remain qualified unless a user +explicitly imports one of those submodules. + +## Scenario composition + +- CompositeModel structure: `CompositeModel`, `Object`, `ObjectId`, `CompositeModelTemplate`, + `ObjectInstance`, `Override`. +- Applications: `ModelSpec`, `Environment`, and `Updates`. +- 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`. +- Label criteria are selector keywords: `kind`, `species`, `scale`, and + `name`. +- 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`, `final_state`. +- 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!`. + +## Diagnostics namespace + +`PlantSimEngine.Diagnostics` owns structured explanations and supported +inspection: + +- Structure: `Diagnostics.explain_objects`, `Diagnostics.explain_instances`, `Diagnostics.explain_scopes`. +- Compilation: `Diagnostics.explain_applications`, `Diagnostics.explain_bindings`, + `Diagnostics.explain_calls`, `Diagnostics.explain_writers`, + `Diagnostics.explain_schedule`, `Diagnostics.explain_execution_plan`. +- Initialization, environment, and outputs: `Diagnostics.explain_initialization`, + `Diagnostics.explain_environment`, `Diagnostics.explain_environment_bindings`, + `Diagnostics.explain_output_retention`, `Diagnostics.explain_outputs`. +- Supported carrier inspection: `Diagnostics.input_carrier`, `Diagnostics.input_value`, + `Diagnostics.has_reference_carrier`. +- Normalized selector addresses: `Diagnostics.ObjectAddress`, + `Diagnostics.object_address`. + +## Model-author contract + +- Model identity: `AbstractModel`, `@process`, `process`. +- State schema and initialization: `Status`, `Required`, `Default`, + `init_variables`, `dep`. +- Model IO inspection: `inputs`, `outputs`, `variables`, + `environment_inputs`, `environment_outputs`, + `validate_environment_inputs`. +- Timing and routing traits: `timespec`, `output_policy`, `timestep_hint`, + `environment_hint`, `environment_bindings`, `environment_window`. + +The underscore declarations `inputs_`, `outputs_`, `environment_inputs_`, and +`environment_outputs_` are intentionally unexported extension functions. +Model authors implement them with qualified definitions such as +`PlantSimEngine.inputs_(model) = ...`. `inputs_` must return explicit +`Required(T)` or `Default(value)` declarations; `outputs_` returns initial +output-state values. + +## Time and reducers + +- Scheduling: `ClockSpec`, `SchedulePolicy`, `HoldLast`, `Interpolate`, + `Integrate`, `Aggregate`. +- Meteorology reducers are not re-exported. Use qualified PlantMeteo names, + for example `PlantMeteo.MeanReducer` or `PlantMeteo.RadiationEnergy`. + +## EnvironmentAPI namespace + +- Backend contract: `EnvironmentAPI.AbstractEnvironmentBackend`, `EnvironmentAPI.EnvironmentContext`, + `EnvironmentAPI.GlobalConstant`, `EnvironmentAPI.environment_backend`, `EnvironmentAPI.environment_variables`, + `EnvironmentAPI.base_step_seconds`, `EnvironmentAPI.get_nsteps`, and + `EnvironmentAPI.bind_environment`. +- Sampling and mutation: `EnvironmentAPI.sample`, + `EnvironmentAPI.sample_environment`, `EnvironmentAPI.commit_environment!`, + and `EnvironmentAPI.update_index!`. +- PlantMeteo conveniences: `Atmosphere`, `Constants`, `Weather`. + +## GraphEditor namespace + +- Discovery and DTOs: `GraphEditor.available_models`, + `GraphEditor.model_descriptor`, `GraphEditor.compile_model_report`, + `GraphEditor.ModelGraphView`, and `GraphEditor.model_graph_view`. +- Serialization and static views: `GraphEditor.model_graph_view_json`, + `GraphEditor.model_graph_view_html`, and + `GraphEditor.write_model_graph_view`. +- Semantic edits and sessions live under the same namespace, including + `GraphEditor.AddModelApplication`, `GraphEditor.apply_model_graph_edit`, + `GraphEditor.edit_graph`, `GraphEditor.current_model`, + `GraphEditor.undo!`, and `GraphEditor.redo!`. + +## Evaluation namespace + +- Fitting and metrics: `Evaluation.fit`, `Evaluation.RMSE`, + `Evaluation.NRMSE`, `Evaluation.EF`, and `Evaluation.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`; +- 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. + +The namespace-boundary test in `test/test-model-api-stabilization.jl` compares +the complete default public-name set with an explicit inventory and separately +checks every focused submodule. Adding or removing an export therefore requires +an intentional inventory update. 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..2c1b18c56 100644 --- a/docs/src/agent_skill.md +++ b/docs/src/agent_skill.md @@ -8,12 +8,29 @@ 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 `on`; +- coupling values and manual model calls with `inputs` and `calls`; +- configuring multirate simulations with `every`, `Dates.Period` values, + and temporal policies; +- binding global or spatial microclimate through `Environment`; +- reasoning about model-clock weather aggregation, `environment_hint` reducers, and + scenario source overrides; +- inspecting compiled scenarios with structured explanation helpers; +- reporting supplied, generated, bound, and unresolved variables with + `Diagnostics.explain_initialization`; +- accessing the live model from lifecycle-capable kernels with + `runtime_model(context)`; +- inspecting homogeneous runtime batches with `Diagnostics.explain_execution_plan`; +- collecting raw or requested model outputs with `outputs`, + `OutputRequest`, `collect_outputs`, and `Diagnostics.explain_output_retention`; +- implementing or wrapping models with `@process`, `inputs_`, `outputs_`, + `run!`, hard dependencies, and model traits. 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/assets/.gitignore b/docs/src/assets/.gitignore new file mode 100644 index 000000000..e02769089 --- /dev/null +++ b/docs/src/assets/.gitignore @@ -0,0 +1 @@ +/model_graph_example.html diff --git a/docs/src/composite_model/quickstart.md b/docs/src/composite_model/quickstart.md new file mode 100644 index 000000000..988ae41cc --- /dev/null +++ b/docs/src/composite_model/quickstart.md @@ -0,0 +1,261 @@ +# 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. Applications use one direct constructor: + +```julia +ModelSpec( + model; + name=:application, + on=Many(scale=:Leaf), + inputs=(...), + calls=(...), + every=Dates.Hour(1), + environment=Environment(...), + output_routing=(...), + updates=Updates(...), +) +``` + +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 `every`, 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 `Simulation` provides a snapshot of the latest status values: + +```@example model_object_quickstart +scene_status = final_state(sim) +(TT_cu=scene_status.TT_cu, LAI=scene_status.LAI, aPPFD=scene_status.aPPFD) +``` + +## Inspect The Compiled Bindings + +Before running, `Diagnostics.explain_initialization(model)` classifies each variable as +`:required`, `:defaulted`, `:supplied`, `:producer_bound`, `:generated`, or +`:environment_bound`. The report remains available when 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(Diagnostics.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(Diagnostics.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 +Diagnostics.explain_output_retention(requested_sim) +``` + +## Many Objects As Inputs + +Use `ModelSpec(...; 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, on=One(scale=:Scene), inputs=(:plant_surfaces => Many( + scale=:Plant, + within=SceneScope(), + var=:surface, + ),)), + ), +) + +plant_sim = run!(plant_scene) +plant_model_status = final_state(plant_sim, One(scale=:Scene)) +(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(Diagnostics.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 `ModelSpec(...; 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, on=One(scale=:Scene), calls=(:leaf_energy => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:energy_balance, + ), + :soil => One( + kind=:soil, + scale=:Soil, + within=SceneScope(), + application=:soil_water, + ),), every=Hour(1)) +``` + +For a one-shot call, execute all targets directly: + +```julia +targets = run_call!(context, :leaf_energy; 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, status, environment, + constants, context) + trial = trial_environment(model, status) + run_call!(context, :leaf_energy; environment=trial, publish=false) + + accepted = accepted_environment(model, status) + commit_environment!(context, accepted) + run_call!(context, :leaf_energy; publish=true) + + return nothing +end +``` + +`run_call!` defaults to `publish=false`, so trial calls mutate target statuses +without publishing temporal streams or committing mutable environment updates. +Pass non-committing trial state with `environment=trial_state`, then call +`commit_environment!` before publishing the accepted state. + +## 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`, and `environment_inputs_`. +- [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..1e75bafb0 --- /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` | +| Environment sampling | `src/time/runtime/environment_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 `on`, `inputs`, `calls`, `every`, + `Environment`, `Updates`, and `output_routing` 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..828ea4573 --- /dev/null +++ b/docs/src/dev/composite_model_completion_audit.md @@ -0,0 +1,96 @@ +# 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 +Updates +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 `ModelSpec(...; inputs=...)` bindings, same-object inference, scalar references, `RefVector`, `Advanced.ObjectRefVector`, temporal carriers, renaming, and optional inputs | +| Manual hard dependencies | Compiled `ModelSpec(...; 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 | `every=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 | `environment_inputs_`, global and spatial backends, ancestor geometry fallback, source remapping, model hints, tabular aggregation, cached opaque handles, `run_call!(...; environment=trial_state)` trial sampling, and `commit_environment!` accepted mutable commits | +| 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 | `Required(T)` and `Default(value)` make the input contract explicit; `Diagnostics.explain_initialization` classifies required, defaulted, supplied, generated, producer-bound, and environment-bound variables before strict compilation | +| 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, `output_routing`, `OutputRequest(application=...)`, dynamic-object exports, and bounded policy-specific dependency histories | +| MAESPA acceptance case | `build_maespa_scene` and `run_maespa_example` use `CompositeModelTemplate`, `ObjectInstance`, `on`, `inputs`, `calls`, and `every`; 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..f0fd4b22a --- /dev/null +++ b/docs/src/dev/composite_model_design.md @@ -0,0 +1,719 @@ +# 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 +ModelSpec( + model; + name=:application, + on=Many(scale=:Leaf), + inputs=(...), + calls=(...), + every=Dates.Hour(1), + environment=Environment(...), + output_routing=(...), + updates=Updates(...), +) +``` + +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) +Many(kind=:plant) +Many(species=:oil_palm) +``` + +`Self()` means only the current object: the object on which the consuming +application runs. It never means the model, species, or plant unless that +object is itself the plant. `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, + application=: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, on=Many(kind=:plant, scale=:Leaf), inputs=(...), calls=(...), every=Hour(1)) +``` + +Application names are optional when a process occurs only once. Scenario +declarations identify singular producers and call targets with +`application=:sunlit_photosynthesis`; `process=...` is reserved for explicit +discovery queries such as `Many(process=:photosynthesis)`. + +## 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 `ModelSpec(...; on=...)` 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(); on=Many(kind=:plant, scale=:Leaf)) + +ModelSpec(AllocationModel(); on=Many(kind=:plant, scale=:Plant)) + +ModelSpec(SceneEB(); on=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 `ModelSpec(...; 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(); on=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: + +- `ModelSpec(...; inputs=...)` means "give this model values"; the runtime schedules or + samples producers; +- the receiving model never manually calls the producer because of an + `ModelSpec(...; 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, environment 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 `ModelSpec(...; inputs=...)` declaration, not a +separate mapping language. The public time language should remain `Dates` +periods. + +Example: + +```julia +ModelSpec(PlantAllocation(); on=Many(kind=:plant, scale=:Plant), inputs=(:leaf_assimilation => Many( + scale=:Leaf, + within=Subtree(), + var=:assimilation, + policy=Integrate(), + window=Day(1), + )), every=Day(1)) +``` + +Policy precedence should stay explicit: + +1. input-level policy in `ModelSpec(...; 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, + application=: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 `ModelSpec(...; 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: + +- `ModelSpec(...; 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. + +`Diagnostics.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 `ModelSpec(...; inputs=...)` or `ModelSpec(...; 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 +Diagnostics.ObjectAddress( + scope, + kind, + species, + scale, + name, + process, + application, + var, + relation, + policy, + window, + from_status, + after, + multiplicity, +) +``` + +Only the compiler works with this normalized address. Users should not need to +construct it manually. [`Diagnostics.object_address`](@ref) is the structured diagnostic +view and preserves every normalized selector field, including temporal and +status-routing fields. + +## Object Lifecycle And Spatial Contracts + +Growth, pruning, organ creation, reparenting, and moving organs must all update +the same compiled caches: + +- object selections used by `on`, `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(); on=Many(scale=:Leaf), updates=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 +environment_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 environment backend gives every object the current sampled 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 the global environment 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 +handle = EnvironmentAPI.bind_environment(backend, object, context, config) +EnvironmentAPI.sample(backend, handle, variable, time) +EnvironmentAPI.sample(backend, handle, trial_state, variable, time) +commit_environment!(backend, handle, accepted_state, time) +EnvironmentAPI.update_index!(backend, changed_entities, removed_object_ids) +``` + +`environment_inputs_(model)` declares what a model reads from the active environment +provider, while `environment_outputs_(model)` declares what it may commit. Controllers +commit accepted mutable microclimate state with +`commit_environment!(context, accepted_state)` and run trial descendants with +`run_call!(context, name; environment=trial_state)`. Simple global meteorology +remains the default provider. + +Scenario-level environment source remapping belongs on `Environment(...)`, for +example: + +```julia +ModelSpec(LeafGasExchange(); name=:gas_exchange, on=Many(scale=:Leaf), environment=Environment(provider=:global, sources=(CO2=:Ca,))) +``` + +Here the model reads `environment.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 +`ModelSpec(...; every=...)`. PlantMeteo samples the table with the reducer and window from +`environment_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 `ModelSpec(...; inputs=...)`; +- callable dependencies from `ModelSpec(...; 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 +Diagnostics.explain_objects(model) +Diagnostics.explain_instances(model) +Diagnostics.explain_scopes(model) +Diagnostics.explain_bindings(sim) +Diagnostics.explain_calls(sim) +Diagnostics.explain_environment_bindings(sim) +Diagnostics.explain_schedule(sim) +Diagnostics.explain_writers(sim) +Diagnostics.explain_execution_plan(sim) +Diagnostics.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. Model kernels use +`run!(model, status, environment, constants, context)` while the scenario +configuration surface uses `CompositeModel`, `Object`, `ModelSpec`, selectors, +`ModelSpec(...; inputs=...)`, `ModelSpec(...; calls=...)`, `ModelSpec(...; every=...)`, 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..85fb9c279 --- /dev/null +++ b/docs/src/dev/composite_model_implementation_plan.md @@ -0,0 +1,1035 @@ +# 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 +ModelSpec( + model; + name=:application, + on=Many(scale=:Leaf), + inputs=(...), + calls=(...), + every=Dates.Hour(1), + environment=Environment(...), + output_routing=(...), + updates=Updates(...), +) +``` + +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 typed application metadata, now constructed + directly with `ModelSpec` keywords. +- Added `ModelSpec(model; name=...)` application names and getters: + `application_name`, `applies_to`, `value_inputs`, `model_calls`, and + `environment_config`. +- Added selector and address types: `SceneScope`, `Self`, `Subtree`, + `SelfPlant`, `Ancestor`, `Scope`, `Relation`, `One`, `OptionalOne`, `Many`, + and `Diagnostics.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(...)`, label keywords such as `kind=:plant` and + `scale=:Leaf`, and `One`/`OptionalOne`/`Many` cardinality checks. +- Added `Diagnostics.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 `ModelSpec(...; inputs=...)` can use these + relations without runtime selector resolution. +- `Diagnostics.ObjectAddress(selector)` normalizes scope, relation, label, routing, + temporal-policy, and status-ordering fields into one structured diagnostic + record. +- Started the object-address compiler with `Advanced.compile_composite_model(model, specs)` and + compiled model application/binding carriers. The compiler now resolves + `ModelSpec(...; on=...)` target object ids, object-relative `ModelSpec(...; inputs=...)` source + object ids, and object-relative `ModelSpec(...; calls=...)` callee object/application ids + before runtime. +- Added `Diagnostics.explain_applications`, `Diagnostics.explain_bindings`, and `Diagnostics.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, `ModelSpec(...; inputs=...)` bindings now precompile a scalar shared + `Ref`, a homogeneous `RefVector`, or an `Advanced.ObjectRefVector` fallback for + heterogeneous reference-preserving vectors. `Diagnostics.input_carrier`, `Diagnostics.input_value`, + and `Diagnostics.has_reference_carrier` expose these carriers, and `Diagnostics.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 + `ModelSpec(...; inputs=...)`, and exactly one other application on the same object outputs + the same variable, `Advanced.compile_composite_model` creates an inferred reference binding. + `Diagnostics.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 `ModelSpec(...; inputs=...)` + selector uses `process=` or `application=`, `Advanced.compile_composite_model` validates that a + matching source application exists for the selected source objects. + `Diagnostics.explain_bindings` reports `source_application_ids`, `process`, and + `application` for agent-readable dependency diagnostics. +- Dependency selectors in `ModelSpec(...; inputs=...)` and `ModelSpec(...; 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(T)` 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_` fields and + `Default(value)` inputs from their initial values, and installs explicitly or + implicitly bound input carriers. Unbound `Required(T)` inputs remain + compilation errors. +- `Advanced.compile_composite_model` now rejects `ModelSpec(...; 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 `ModelSpec(...; 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 `ModelSpec(...; inputs=...)`, for example + `inputs=(:renamed_signal => One(within=Self(), var=:signal),)`. The compiler + aliases the source `Ref`, records the renamed source variable in + `Diagnostics.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 + `Diagnostics.explain_environment_bindings`. The compiler resolves each + application/object environment provider, backend, required + `environment_inputs_`, 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 `EnvironmentAPI.update_index!(backend, changed_entities, removed_object_ids)` once per + distinct backend before `Advanced.EnvironmentAPI.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 `environment_inputs_` changes while application + id, object, process, provider, backend, status, and geometry provenance remain + unchanged, required metadata is updated while the cached cell is reused + without `EnvironmentAPI.update_index!` or `Advanced.EnvironmentAPI.bind_environment`. +- `validate_environment_inputs(model)` and + `validate_environment_inputs(compiled_scene, environment_or_backend)` now validate + composite-model/object model application `environment_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 `Diagnostics.explain_environment_bindings` reports both `required_inputs` + and `source_inputs`. +- CompositeModel applications now infer model-author default environment source remaps + from `environment_hint(...).bindings` when the scenario does not provide explicit + environment bindings. Scenario `Environment(; sources=...)` keeps precedence over + the trait. +- Global tabular meteorology is now sampled at each model application's + compiled clock. `environment_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 `ModelSpec(...; 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 `ModelSpec(...; 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 `ModelSpec(...; 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 `ModelSpec(...; 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 exposes explicit mutable environment + commits through `commit_environment!(context, accepted_state)` and + non-committing trial sampling through + `run_call!(context, name; environment=trial_state)`. + Meteorological state stays in the environment backend instead of being staged + through same-named status values. +- Added root application scheduling from `ModelSpec(...; every=...)` using `Dates.Period` + values and the model environment base step. `Diagnostics.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 `ModelSpec(...; every=...)` is omitted. Scenario-level `ModelSpec(...; every=...)` 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 `ModelSpec(...; every=...)` or non-default `timespec(...)`. +- `Advanced.compile_composite_model` now computes a stable topological application order from + resolved `ModelSpec(...; inputs=...)` producer edges and `Updates(...)` writer-order edges. + Inputs produced by manual-call-only applications are redirected to the parent + application that owns the `ModelSpec(...; calls=...)` call stack. `run!(model)` uses this + precompiled order instead of user declaration order, cycles fail at compile + time, and `Diagnostics.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 + `ModelSpec(...; calls=...)` targets with `call_targets(context, :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 `ModelSpec(...; 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 `Diagnostics.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 `ModelSpec(...; 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 `ModelSpec(...; inputs=...)` support. Manual value-transfer carriers are not + retained as user-authored API. +- Removed the intermediate dependency resolver after `ModelSpec(...; 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 + `ModelSpec(...; on=...)` selectors. Their `ModelSpec(...; inputs=...)`, `ModelSpec(...; 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=..., 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 `Diagnostics.explain_instances(model)` + reports each instance root, current subtree object ids, mounted application + ids, instance/object overrides, template labels, and parameter ownership. + `Diagnostics.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 can run under temporary local meteorology with + `run_call!(context, name; environment=local_state)`. Descendants sample the + temporary state through normal environment bindings, while `publish=false` + suppresses output publication and environment commits. This supports + iterative microclimate solvers such as the MAESPA model energy-balance loop. +- Model kernels read their own parameters from the `model` argument. Generic + hard-dependency kernels such as `Monteith` and `Fvcb` discover declared call + targets through `call_targets` and execute them through `run_call!`. +- 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`, `on`, `inputs`, `calls`, + and `every=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 + `Diagnostics.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 `ModelSpec(...; 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 `Diagnostics.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 `output_routing=(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=(... One(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 `ModelSpec(...; on=...)` 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.EnvironmentAPI.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. +- Root model execution now compiles contiguous homogeneous target batches. + Each target prebinds its concrete model, `Status`, 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 `Diagnostics.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. +- `ModelSpec(...; on=selector)` as the target object-set declaration. +- `ModelSpec(...; inputs=...)` for value dependencies. +- `ModelSpec(...; calls=...)` for manual call-stack dependencies. +- `Updates(...)` for rare ordered duplicate writers. +- `every=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 `environment_inputs_`, 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() +Subtree() +SelfPlant() +Ancestor(scale=:Plant) +Scope(name) +Relation(...) +``` + +Object labels use keyword criteria such as `kind=:plant`, +`species=:oil_palm`, `scale=:Leaf`, and `name=:leaf_1`. + +Implement multiplicity wrappers: + +```julia +One(selector...) +OptionalOne(selector...) +Many(selector...) +``` + +Selectors must normalize to `Diagnostics.ObjectAddress` objects with enough context to be +resolved relative to a consuming object. + +Implement `ModelSpec(...; on=...)` 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 object: the object on which the consuming + model application runs. It is a plant only when that object is the plant. +- `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 `ModelSpec(...; on=...)` produces stable + application/object pairs; +- selector errors report available labels and near matches. + +## Phase 3: Unified Value Inputs + +Goal: use `ModelSpec(...; inputs=...)` as the only user-facing value-dependency declaration. +Historical `MultiScaleModel(...)` mappings are migration sources only. + +Target API: + +```julia +ModelSpec(AllocationModel(); on=Many(kind=:plant, scale=:Plant), inputs=(:leaf_carbon => Many(scale=:Leaf, within=Subtree(), var=:leaf_carbon))) + +ModelSpec(LAIModel(area); on=One(scale=:Scene), inputs=(:leaf_areas => Many(kind=:plant, scale=:Leaf, within=SceneScope(), var=:leaf_area))) +``` + +Implement: + +- `ModelSpec(...; 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 `Diagnostics.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 `ModelSpec(...; inputs=...)` and + produces the same `lai` and `leaf_area`; +- historical plant allocation `MultiScaleModel([:leaf_carbon => [:Leaf => :leaf_carbon]])` + becomes `ModelSpec(...; inputs=...)` and remains plant-local; +- a same-scale rename currently expressed with `SameScale()` works through + `ModelSpec(...; 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 `ModelSpec(...; inputs=...)` without forced + conversion to `Float64`. + +## Phase 4: Unified Model Calls + +Goal: use `ModelSpec(...; 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(); + on=One(scale=:Scene), + calls=( + :leaf_energy => + Many(kind=:plant, scale=:Leaf, process=:energy_balance), + :soil => One(kind=:soil, application=:soil_water), + ), +) +``` + +Implement: + +- `ModelSpec(...; calls=...)` as `ModelSpec` configuration. +- `Call(...)` or an equivalent internal wrapper that lets `dep(model)` provide + default manual-call dependencies. +- call resolution from `Diagnostics.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 `ModelSpec(...; 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. +- `Diagnostics.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 `ModelSpec(...; inputs=...)` and `ModelSpec(...; calls=...)` are reported as `:model_spec`, + and compiler-created same-object value links are reported as + `:inferred_same_object`. `Diagnostics.explain_bindings`, `Diagnostics.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 + `ModelSpec(...; on=...)` 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 environment and microclimate sampling automatic and fast. + +Implement: + +- `EnvironmentBinding` cache: + object id, backend/provider id, cell/layer id, required variables. +- default environment resolver: + global environment data 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: + `Diagnostics.explain_environment_bindings(sim)`. +- minimal geometry accessors or traits: + `position`, `geometry`, and `bounds`. +- backend protocol: + `Advanced.EnvironmentAPI.bind_environment`, opaque handles, committed/transient `sample`, + `commit_environment!`, and `EnvironmentAPI.update_index!`. +- `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 environment values +``` + +Spatial lookup must happen only during binding refresh, not inside every model +call. + +Acceptance tests: + +- global environment data gives the same values to all objects; +- missing global environment variables fail during environment binding refresh when + the backend can enumerate variables; +- `Environment(; sources=...)` remaps backend variables to model-facing + `environment_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 `environment_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 `environment_inputs_` changes update required variables without recomputing + spatial links unless necessary. +- `commit_environment!(context, accepted_state)` commits mutable microclimate + state back to the active backend. + +## 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 `ModelSpec(...; on=...)` target sets; +- multirate scheduling based on `Dates.Period` values in `ModelSpec(...; every=...)` 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: + `Diagnostics.explain_objects`, `Diagnostics.explain_scopes`, `Diagnostics.explain_bindings`, + `Diagnostics.explain_calls`, `Diagnostics.explain_environment_bindings`, `Diagnostics.explain_schedule`, + `Diagnostics.explain_writers`. + +Acceptance tests: + +- old `MultiScaleModel` examples rewritten with `ModelSpec(...; inputs=...)` produce matching + outputs; +- historical cross-object examples rewritten with `ModelSpec(...; inputs=...)` produce + matching outputs; +- MAESPA hard-call example rewritten with `ModelSpec(...; calls=...)` produces matching + outputs; +- explanation helpers include enough concrete object ids, scales, processes, + and variables for an AI agent to repair bad mappings. +- `Diagnostics.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 `ModelSpec(...; inputs=...)`; +- manual dependency declarations -> `ModelSpec(...; calls=...)`; +- repeated species assemblies -> `CompositeModelTemplate` plus `ObjectInstance`; +- explicit environment wiring -> environment resolver/binding backend. +- `InputBindings(...)` -> source and temporal policy information inside + `ModelSpec(...; inputs=...)`; +- `MeteoBindings(...)` and `MeteoWindow(...)` -> `Environment(...)` and + environment sampling/window policy; +- `ModelSpec(...; output_routing=...)` -> model-application output policy; +- `PreviousTimeStep(...)` -> temporal policy/cycle-breaking marker in the + unified graph; +- `ScopeModel(...)` -> `ModelSpec(...; on=...)` 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`, `on`, + `inputs`, `every`, automatic same-object binding inference, multi-object + `Many(...)` inputs, and manual `ModelSpec(...; 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, `ModelSpec(...; inputs=...)` multi-object coupling, conceptual + `ModelSpec(...; 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 + `ModelSpec(...; inputs=...)`, `RefVector` carrier explanations, and manual `ModelSpec(...; 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 + `ModelSpec(...; inputs=...)`, manual `ModelSpec(...; calls=...)`, `Updates(...)`, `ModelSpec(...; every=...)`, + 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`, + `on`, `every`, inferred soft `ModelSpec(...; inputs=...)`, and manual + `ModelSpec(...; 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, environment 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 environment-contract validation parity for + missing environment variables, explicit `Environment(; sources=...)` + remapping, model-author `environment_hint` source defaults, and validation against + an explicit replacement environment object/backend. +- Test code uses the canonical `ModelSpec(...; every=...)` 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 `on`, + `inputs`, `calls`, `call_targets`, and `run_call!`. + +## Resolved API Decisions + +- `SceneScope`, `Self`, `Subtree`, `SelfPlant`, `Ancestor`, `Scope`, and + `Relation` are the public topology selector names. Object labels use + `kind=`, `species=`, `scale=`, and `name=` keyword criteria. +- `ModelSpec(...; inputs=...)` is the only scenario-level value-binding + construction form. +- `ModelSpec(...; every=...)` is the canonical timestep configuration. +- `Environment(...)` owns provider/resolver/source configuration. Temporal + value windows belong to the consuming `ModelSpec(...; 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..b04d9edf1 --- /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 canopy-air, leaf, and +soil calls. `ModelSpec(...; calls=...)` expresses execution ownership only: the scene model +decides when subprocesses run. + +```julia +ModelSpec(scene_model; name=:scene_eb, on=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),), environment=Environment(provider=:forcing, sink=:canopy), every=Dates.Hour(1)) +``` + +The scene receives above-canopy forcing from the `:forcing` provider and has +`:canopy` as its explicit commit sink. Trial leaf calls use +`run_call!(context, :energy_balance; environment=trial_environment)`, so all hard-called +leaves sample the trial canopy atmosphere through their compiled handles without +committing it. After convergence, the scene commits the accepted canopy +atmosphere with `commit_environment!(context, accepted_environment)` and publishes one +accepted leaf call against that committed environment. + +Scene/soil values are wired declaratively with `ModelSpec(...; 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, on=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, + ),), every=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, on=One(scale=:Scene), inputs=(:leaf_areas => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + process=:leaf_state, + var=:leaf_area, + ),), every=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, on=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, on=One(scale=:Plant), inputs=(:leaf_carbon => Many(scale=:Leaf, within=Subtree(), var=:leaf_carbon)), every=Dates.Day(1)) +``` + +## Meteorology + +Input meteorology is above-canopy forcing wrapped in a +`MaespaSingleLayerEnvironment`. The backend stores two meteorological states: + +- `forcing`: the above-canopy `Weather`/time series sampled by the scene; +- `canopy`: the mutable canopy `Atmosphere` sampled by every leaf. + +The scene application uses `Environment(provider=:forcing, sink=:canopy)`. +Leaf energy-balance applications use `Environment(provider=:canopy)`. The +one-layer backend does not look at process names, geometry, or cells; all leaves +intentionally sample the same current canopy atmosphere. + +`canopy_air_update(...)` is a plain helper, not a model application. It reads +canopy-scale leaf fluxes aggregated in the scene, computes the MAESPA-style +canopy air update, and returns a new `Atmosphere`. The accepted solution is +committed directly with: + +```julia +commit_environment!(context, accepted_environment) +``` + +CompositeModel status also stores diagnostics for the resulting below-canopy +microclimate: + +- `canopy_tair`; +- `canopy_vpd`; +- `canopy_rh`; +- `canopy_htot`; +- `canopy_gcanop`. + +Trial iterations pass the candidate atmosphere through `run_call!`, preserving +the leaf applications' compiled provider handles. The accepted state is the +only state committed to the mutable environment backend. + +## 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..023b63fb8 --- /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, overrides, and `Updates(...; after=...)` use canonical application IDs. Singular process references are rejected; `Many(process=...)` remains an explicit discovery query. | Stabilization, binding-inference, hard-call, output, override, and update tests cover repeated applications and actionable 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. `Diagnostics.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 is `run!(model, status, environment, constants, context)`. Model parameters come from `model`; `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 are removed. Mapping runtimes are not restored. | Migration guide plus rejection 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..fbc768925 --- /dev/null +++ b/docs/src/dev/public_api_refinement_decisions.md @@ -0,0 +1,105 @@ +# 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. + +The former `tracked_outputs` keyword has been removed. Use `outputs` directly; +there is no dual spelling. + +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. +- Superseded CompositeModel/Object spellings are removed rather than retained + as aliases or fallback methods. +- Benchmarks, examples, documentation, PlantBiophysics, and XPalm target the + canonical API. diff --git a/docs/src/dev/release_notes_handoff.md b/docs/src/dev/release_notes_handoff.md new file mode 100644 index 000000000..fc3c28e5f --- /dev/null +++ b/docs/src/dev/release_notes_handoff.md @@ -0,0 +1,482 @@ +# 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 + `context.compiled.model`. +- Added `Diagnostics.explain_initialization(model)` with structured `:required`, + `:defaulted`, `:supplied`, `:generated`, `:producer_bound`, and + `:environment_bound` 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 `ModelSpec(...; inputs=...)`, manual `ModelSpec(...; 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 the + `canopy_air_update(...)` helper and `gbcanms`. +- Treats input meteorology as above-canopy forcing, runs trial leaves with + `run_call!(...; environment=trial_state)`, commits accepted canopy + meteorology with `commit_environment!`, and writes + below-canopy microclimate diagnostics 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 `ModelSpec(...; inputs=...)` and `ModelSpec(...; 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 `Diagnostics.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 object-relative input, call, and query + selectors. Relation results are compiled to concrete object ids and may be + constrained by an explicit scope. Application targets reject relations + because they have no current object context. +- Selector labels now have one spelling (`scale=:Leaf`, `kind=:plant`, + `species=:oil_palm`, and `name=:leaf_1`); the duplicate `Scale`, `Kind`, and + `Species` wrappers were removed. `Diagnostics.ObjectAddress` preserves all normalized + object, routing, temporal, and status-ordering fields, while positional + topology selectors such as `Relation(:parent)` remain supported. +- Adds the first compiled composite-model/object view with `Advanced.compile_composite_model`, + `Advanced.CompiledCompositeModel`, `Advanced.CompiledModelApplication`, `Advanced.CompiledModelInputBinding`, + `Advanced.CompiledModelCallBinding`, `Diagnostics.explain_applications`, + `Diagnostics.explain_bindings`, and `Diagnostics.explain_calls`. +- The compiled model view resolves `ModelSpec(...; on=...)`, `ModelSpec(...; inputs=...)`, and + `ModelSpec(...; 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. `Diagnostics.input_carrier`, `Diagnostics.input_value`, and `Diagnostics.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 without inventing + canonical values for `Required(T)`, and repeated non-temporal input + materialization is allocation-free. +- Same-rate `ModelSpec(...; 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 `ModelSpec(...; inputs=...)` syntax instead of + `SameScale()`. Renamed inputs share the producer reference and contribute the + expected producer-to-consumer scheduling edge. +- `Diagnostics.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 `Diagnostics.explain_environment_bindings`. +- Adds `geometry`, `position`, and `bounds` accessors for model objects/statuses. + Environment binding refreshes call `EnvironmentAPI.update_index!(backend, changed_entities, removed_object_ids)` 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 `environment_inputs_` 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 + `EnvironmentAPI.GlobalConstant`, and explanations expose both `required_inputs` and + `source_inputs`. +- `validate_environment_inputs(model)` and + `validate_environment_inputs(compiled_scene, environment_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 `environment_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 `ModelSpec(...; 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. `Diagnostics.explain_bindings` + reports each binding origin, including `:model_default`, `:model_spec`, and + `:inferred_same_object`. +- Compiled input bindings now validate `ModelSpec(...; inputs=...)` `process=`/`application=` + filters when they are provided, and `Diagnostics.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 `ModelSpec(...; 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 outputs and `Default` + inputs are inserted from their initial values, and bound `Required` inputs + are installed through their compiled carriers. External unbound `Required` + inputs still need explicit initialization. +- `Advanced.compile_composite_model` now rejects `ModelSpec(...; 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 `ModelSpec(...; 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 `ModelSpec(...; inputs=...)` with `HoldLast`, `Integrate`, + and `Aggregate` policies before consumer execution. +- CompositeModel temporal `ModelSpec(...; 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 `environment_hint(...).bindings` when the scenario does not provide explicit + environment bindings. Scenario `Environment(; sources=...)` remains the override. +- CompositeModel/object runtime exposes `run_call!(...; environment=trial_state)` + for non-committing trial meteorology and `commit_environment!` for accepted + mutable environment + commits from model kernels. +- CompositeModel/object root applications now honor `ModelSpec(...; every=...)` values backed by + `Dates.Period` scheduling. `Diagnostics.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 `ModelSpec(...; every=...)` is provided. Scenario-level `ModelSpec(...; every=...)` + 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 `ModelSpec(...; 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 + `Diagnostics.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!(context, :name)` plus `call_targets(context, :name)` for fine-grained manual `ModelSpec(...; calls=...)` + execution. +- Applications selected by `ModelSpec(...; 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 `Diagnostics.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 `Diagnostics.explain_instances(model)` and instance membership in + `Diagnostics.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 support trial microclimate through + `run_call!(context, name; environment=local_state)`, so hard-called + descendants resample the temporary environment through their normal + environment bindings. +- 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. +- `Diagnostics.explain_calls(compiled)` now exposes the manual-call publication contract + through `publication_policy`, `default_publish`, and `accepted_publish` + fields. +- `ModelSpec` now keeps provenance for `ModelSpec(...; inputs=...)` and `ModelSpec(...; 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. +- Model kernels read their own parameters from the `model` argument. Hard-call + models and targets are available through focused context APIs such as + `call_targets`, `run_call!`, and `runtime_model`. +- 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`, `on`, `inputs`, `calls`, and + `every=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 + `Diagnostics.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 `ModelSpec(...; 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 `Diagnostics.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 `ModelSpec(...; 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 `ModelSpec(...; 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 `output_routing=(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=(... One(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. +- CompositeModel root execution now uses compiled homogeneous target batches. Models, + statuses, 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 `Diagnostics.explain_execution_plan(scene_or_simulation)` and a zero-allocation + warmed 128-leaf inner-loop regression gate. +- Manual `ModelSpec(...; calls=...)` handles now use the public + vector-like `run_call!(context, name)` execute-all API, with + `call_targets(context, 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 `environment_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=..., on=..., inputs=(...), calls=(...))`; +- `MultiScaleModel(...)` -> `ModelSpec(...; 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 -> `ModelSpec(...; on=...)` object selectors. +- `InputBindings(...)` -> source, policy, and window information on + `ModelSpec(...; inputs=...)`. +- `MeteoBindings(...)` and `MeteoWindow(...)` -> automatic environment + binding plus `Environment(...)` provider/source overrides. +- `ModelSpec(...; output_routing=...)` -> model-application output policy. +- `ScopeModel(...)` -> `ModelSpec(...; on=...)` plus selector scopes. +- `PreviousTimeStep(...)` remains supported as a temporal/cycle-breaking + marker in the unified object-address graph. +- explicit per-model environment 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`, `on`, `inputs`, `calls`, + `Updates`, `every`, and `Environment`. +- Replaced the documentation home-page quickstart with executable + composite-model/object examples. The page now introduces `CompositeModel`, `Object`, + `ModelSpec`, `on`, `inputs`, `every`, inferred same-object + bindings, multi-object `Many(...)` inputs, and manual `ModelSpec(...; 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 `ModelSpec(...; inputs=...)`, and `ModelSpec(...; 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 `ModelSpec(...; inputs=...)`, + reference carrier explanations, and manual `ModelSpec(...; calls=...)` syntax. +- Rewrote the model execution page as the current composite-model/object execution + guide. It now covers compilation, reference carriers, temporal `ModelSpec(...; inputs=...)`, + manual `ModelSpec(...; calls=...)`, `Updates(...)`, `ModelSpec(...; every=...)`, 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`, `on`, `every`, + 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 `ModelSpec(...; 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..d5eec475e 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 `GraphEditor.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..1c01dd3c1 --- /dev/null +++ b/docs/src/guides/coupling.md @@ -0,0 +1,86 @@ +# 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 `Diagnostics.explain_bindings`. + +Use `calls` only when a parent algorithm owns child execution or iteration. +Use `run_call!(context, :name)` to execute every resolved target. For selective +or iterative execution, retrieve the vector-like collection with +`call_targets(context, :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. `Diagnostics.explain_calls` and `Diagnostics.explain_schedule` +show call-only targets and ordering. + +`Diagnostics.explain_initialization(model)` classifies values as supplied, generated, +producer-bound, defaulted, required, or environment-bound 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, on=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. + +By default, an input selector also identifies applications that produce the +selected variable, and those producers are scheduled before the consumer. Use +`from_status=true` only when the input deliberately reads the objects' current +`Status` references independently of any producer: + +```julia +ModelSpec( + ReserveConsumer(); + inputs=( + :organ_reserves => Many( + scale=(:Leaf, :Internode), + within=Subtree(), + var=:reserve, + from_status=true, + after=:plant_allocation, + ), + ), +) +``` + +This is a same-step live-reference binding. It cannot be combined with +`process`, `application`, `policy`, or `window`. It does not infer a producer +edge; use `after=:application_id` when the state must be read or mutated after +a particular application. Otherwise, the scenario's application order is +preserved. + +## Manual calls + +```julia +ModelSpec(Optimizer(); name=:optimizer, on=Many(scale=:Plant), calls=(:leaf_energy => Many(scale=:Leaf, within=Subtree()))) +``` + +Inside `Optimizer`, iterate over `call_targets(context, :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 `Diagnostics.explain_bindings(compiled)` for source identity +and carrier type, `Diagnostics.explain_calls(compiled)` for call-only targets, and +`Diagnostics.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..abd2c9907 --- /dev/null +++ b/docs/src/guides/data/environment_inputs.md @@ -0,0 +1,10 @@ +# 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 environment 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..309b802a6 --- /dev/null +++ b/docs/src/guides/data/outputs_plotting.md @@ -0,0 +1,58 @@ +# Collecting And Plotting Outputs + +Run a model with `outputs=:all` or an explicit `OutputRequest`, 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. Use `final_state(sim)` when only +the latest values are needed. + +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 `Diagnostics.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 +`Diagnostics.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, status, environment, constants, context) = + (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/extensions/environment_backends.md b/docs/src/guides/extensions/environment_backends.md new file mode 100644 index 000000000..4c157dc09 --- /dev/null +++ b/docs/src/guides/extensions/environment_backends.md @@ -0,0 +1,94 @@ +# Environment Backend Extensions + +This page is for package authors who connect a meteorology, canopy-layer, +voxel, grid, or other spatial provider to PlantSimEngine. Simulation users +normally configure an existing backend with `Environment(...)` and do not need +this protocol. + +## The boundary + +An extension backend subtypes +`PlantSimEngine.EnvironmentAPI.AbstractEnvironmentBackend` and extends +functions in `PlantSimEngine.EnvironmentAPI`. Keep the concrete backend and +handle types in the extension package. They do not belong in PlantSimEngine's +root namespace. + +The required methods are: + +- `base_step_seconds(backend)`: duration of one base step; +- `get_nsteps(backend)`: available number of steps; +- `bind_environment(backend, object, context, config)`: compile one opaque + handle for an application/object pair; +- `sample(backend, handle, variable, time)`: read committed state. + +Implement `environment_variables(backend)` when the variable names can be +enumerated cheaply. PlantSimEngine then validates model environment inputs +while compiling. + +Mutable or structurally indexed backends add only the capabilities they need: + +- `sample(backend, handle, trial_state, variable, time)` for transient trial + states supplied to `run_call!`; +- `commit_environment!(backend, handle, accepted_state, time)` for accepted + state; +- `update_index!(backend, changed_entities, removed_object_ids)` when topology + or geometry changes require a spatial-index refresh. Initial compilation + supplies all entities; later refreshes supply only the structural delta. + +[`ToySpatialEnvironment`](@ref) is the small, tested implementation used by +the user journeys. + +## Compile routing into the handle + +`bind_environment` receives the target `Object`, an +`EnvironmentAPI.EnvironmentContext`, and the payload configured with +`Environment(...)`. Resolve geometry, layer, voxel, provider, and commit sink +there. Return a concrete handle containing everything later sampling and +committing need. + +PlantSimEngine caches that handle. The hot `sample` methods receive it directly, +so they should not search the object registry or repeat spatial routing. +`EnvironmentContext` contains application id, object id, scale, and process; +runtime status is deliberately absent. + +A controller that reads one provider and writes another can encode both routes +in its handle. For example, the scenario may configure +`Environment(provider=:forcing, sink=:canopy)`, while the backend decides what +those names mean. + +## Trial and commit semantics + +Transient sampling and committing are separate backend operations: + +1. a controller passes its typed trial state to + `run_call!(context, name; environment=trial_state, publish=false)`; +2. PlantSimEngine invokes the transient `sample` overload through each target's + already-compiled handle; +3. the controller accepts a state and calls + `commit_environment!(context, accepted_state)`; +4. PlantSimEngine validates the caller's `environment_outputs_` declaration, + then invokes the backend commit through the caller's handle; +5. the controller publishes accepted called-model outputs explicitly with + `publish=true`. + +The model-facing `commit_environment!(context, state)` is root API. The +backend-facing method belongs to `PlantSimEngine.EnvironmentAPI`; extension +packages should qualify it when adding methods. + +## Refresh and diagnostics + +PlantSimEngine calls `update_index!` once per distinct spatial backend before +binding or rebinding affected targets. Geometry changes invalidate the affected +handles; structural changes can also change the set of bound targets. + +Use these public checks instead of reading compiler fields: + +- `validate_environment_inputs(model)` checks declared variables; +- `Diagnostics.explain_environment_bindings(model)` reports each + application/object handle and geometry source; +- `Diagnostics.explain_environment(simulation)` reports the active backend, + variables, step count, and base-step duration. + +Backend tests should cover at least two objects with distinct handles, +committed and transient sampling, rejected undeclared commits, and handle +refresh after movement or geometry changes. diff --git a/docs/src/guides/graph_visualizer_editor.md b/docs/src/guides/graph_visualizer_editor.md new file mode 100644 index 000000000..bac72a90f --- /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 = GraphEditor.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 = GraphEditor.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")) +GraphEditor.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 = GraphEditor.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 = GraphEditor.current_model(session) +close(session) +``` + +Call `GraphEditor.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 = GraphEditor.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..4c6912fa8 --- /dev/null +++ b/docs/src/guides/modelers/port_existing_model.md @@ -0,0 +1,44 @@ +# 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=Required(Float64),) +PlantSimEngine.outputs_(::DocsLAIGrowth) = (lai_next=0.0,) +PlantSimEngine.environment_inputs_(::DocsLAIGrowth) = (T=0.0,) +function PlantSimEngine.run!(m::DocsLAIGrowth, status, environment, constants, context) + status.lai_next = status.lai + m.rate * environment.T +end + +model = CompositeModel( + DocsLAIGrowth(0.02); + status=(lai=1.0,), + environment=(T=10.0, duration=Day(1)), +) +simulation = run!(model) +@assert final_state(simulation).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. +`Diagnostics.explain_initialization(model)` should show `lai` as supplied, `T` as +environment-bound, and `lai_next` as generated. + +Use `Default(value)` only when the scientific model really defines a fallback. +Do not translate an old sentinel such as `-Inf` into a default: use +`Required(T)` when the value must come from the scenario. + +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..cc6b59ffb --- /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..9d201c6e4 --- /dev/null +++ b/docs/src/guides/multiscale/concepts.md @@ -0,0 +1,32 @@ +# How Multiscale Composite Models Execute + +One application executes once for every object selected by its +`ModelSpec(...; on=...)` selector. 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 | `ModelSpec(model; on=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 +`Diagnostics.explain_applications`, `Diagnostics.explain_objects`, and `Diagnostics.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..d36f6e307 --- /dev/null +++ b/docs/src/guides/multiscale/manual_calls.md @@ -0,0 +1,16 @@ +# Manual Calls Across Objects + +Declare parent-owned execution with +`ModelSpec(model; calls=(:name => One(...),))` or a `Many(...)` selector. In +the kernel, execute every resolved target with `run_call!(context, :name)`. +The returned `CallTargets` collection is always vector-like, including for +`One` and `OptionalOne`. Use +`call_targets(context, :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 after the application that made the change. New or removed objects +therefore affect later applications in the current timestep, but never +recursively affect the mutation-producing kernel call itself. diff --git a/docs/src/guides/multiscale/value_coupling.md b/docs/src/guides/multiscale/value_coupling.md new file mode 100644 index 000000000..9fe7675a7 --- /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 +`Diagnostics.input_carrier`, `Diagnostics.input_value`, and `Diagnostics.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..abb2acf14 --- /dev/null +++ b/docs/src/guides/multiscale/visualizing_structure.md @@ -0,0 +1,6 @@ +# Visualizing Composite model Structure + +Use `Diagnostics.explain_objects`, `Diagnostics.explain_scopes`, and `Diagnostics.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..9fd6a2054 --- /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 `environment_hint` entries. + +Use `Diagnostics.explain_bindings`, `Diagnostics.explain_environment_bindings`, and +`Diagnostics.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..759377e8d --- /dev/null +++ b/docs/src/guides/time/hourly_daily_weekly.md @@ -0,0 +1,63 @@ +# 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 `every` value 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 `Diagnostics.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=Required(Float64),) +PlantSimEngine.outputs_(::DocsHourlyFlux) = (flux=0.0,) +PlantSimEngine.run!(::DocsHourlyFlux, status, environment, constants, context) = + (status.flux = status.rate) +PlantSimEngine.inputs_(::DocsDailyTotal) = (fluxes=Required(Vector{Float64}),) +PlantSimEngine.outputs_(::DocsDailyTotal) = (total=0.0,) +PlantSimEngine.run!(::DocsDailyTotal, status, environment, constants, context) = + (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, on=Many(scale=:Leaf), every=Hour(1)), + ModelSpec( + DocsDailyTotal(); + name=:daily, + on=One(scale=:Plant), + inputs=( + :fluxes => Many( + scale=:Leaf, within=Subtree(), application=:hourly, var=:flux, + policy=Integrate(), window=Day(1), + ), + ), + every=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 `every=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..77f0b5807 --- /dev/null +++ b/docs/src/guides/time/multirate_concepts.md @@ -0,0 +1,12 @@ +# Understanding Model Cadence + +`ModelSpec(...; every=Dates.Period)` sets application cadence. Without +`every`, 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..22cca191c 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,277 @@ 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 - -**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. +`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`. -## Batteries included +The public scenario API has one application-construction form: -- **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: - -- 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) - -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. - -## Ask Questions +```julia +ModelSpec( + model; + name=:application, + on=Many(scale=:Leaf), + inputs=(...), + calls=(...), + every=Dates.Hour(1), + environment=Environment(...), + output_routing=(...), + updates=Updates(...), +) +``` -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**: `ModelSpec(...; 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 `every=Dates.Hour(1)` or + `every=Dates.Day(1)`, with temporal policies such as `Integrate()` or + `HoldLast()` in the same model. +- **Automatic environment binding**: global weather and spatial microclimate + backends are bound through `Environment(...)`, model `environment_inputs_`, and + explicit accepted-state commits with `commit_environment!`. +- **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 `every` 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(Diagnostics.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 `ModelSpec(...; 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, on=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 +plant_sim = run!(plant_scene) +scene_status = final_state(plant_sim, One(scale=:Scene)) +scene_status ``` -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]) -``` +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. -An example output of a multiscale simulation is shown in the documentation of PlantBiophysics.jl: +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. -![Plant growth simulation](www/image.png) +## Manual Calls For Iterative Solvers -### Multi-rate modeling +Use `ModelSpec(...; calls=...)` when a parent model must directly run another model, for +example a model energy-balance solver that iterates leaf temperatures until +convergence: -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](./multirate/introduction.md) -- [Step-by-step hourly, daily, weekly simulation](./multirate/multirate_tutorial.md) -- [Advanced multi-rate configuration](./multirate/advanced_configuration.md) - -## State of the field +```julia +ModelSpec(SceneEnergyBalance(); name=:scene_energy, on=One(scale=:Scene), calls=(:leaf_energy => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:energy_balance, + ), + :soil => One( + kind=:soil, + scale=:Soil, + within=SceneScope(), + application=:soil_water, + ),), every=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!(context, :leaf_energy)` to execute every target and +receive a vector-like collection. Iterative parents use +`call_targets(context, :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 + +- [A mental model](journeys/users/mental_model.md) introduces the seven ideas + used throughout the framework without configuration details. +- [Couple models on one object](journeys/users/one_object.md) is the first + executable journey and runs a coupled simulation over many timesteps. +- [Run the coupling on several objects](journeys/users/several_objects.md) + introduces stable object identity and `Many`. +- [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`, and `environment_inputs_`. +- [Migration guide](migration_composite_model.md) covers upgrades from earlier + PlantSimEngine releases. -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 +Diagnostics.explain_bindings(model) +Diagnostics.explain_schedule(model) +Diagnostics.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/journeys/modelers/basic_model.md b/docs/src/journeys/modelers/basic_model.md new file mode 100644 index 000000000..4e7cce2e7 --- /dev/null +++ b/docs/src/journeys/modelers/basic_model.md @@ -0,0 +1,156 @@ +# Implement A Basic Model + +**New concept:** the complete one-step model contract. This page then proves +that the same kernel composes automatically on one object and runs unchanged +over several objects. + +For the simulation-user view of these scenarios, see +[Couple Models On One Object](@ref) and +[Run The Coupling On Several Objects](@ref). + +## Model 1: declare the complete contract + +`ToyDevelopmentModel` has one generic parameter, one required input, one true +default, one output initial value, and the final five-argument kernel. This is +the complete tested implementation from `examples/ToyModelDeveloper.jl`: + +```julia +struct ToyDevelopmentModel{T} <: AbstractToy_DevelopmentModel + efficiency::T +end + +PlantSimEngine.inputs_(::ToyDevelopmentModel) = ( + TT=Required(Real), + stress=Default(1.0), +) +PlantSimEngine.outputs_(model::ToyDevelopmentModel) = ( + growth=zero(model.efficiency), +) + +function PlantSimEngine.run!( + model::ToyDevelopmentModel, + status, + environment, + constants, + context, +) + status.growth = model.efficiency * status.TT * status.stress + return nothing +end +``` + +`Required(Real)` is a contract, not an initialization value. `Default(1.0)` +means the scientific model genuinely defines unstressed growth as its +fallback. Output values initialize model state and should match the parameter's +numeric type where practical. + +Test the kernel directly before involving a scenario: + +```@example modeler_basic +using Dates, PlantMeteo, PlantSimEngine, DataFrames +using PlantSimEngine.Examples + +development = ToyDevelopmentModel(0.5) +status = Status(TT=8.0, stress=0.75, growth=0.0) +PlantSimEngine.run!( + development, + status, + nothing, + nothing, + nothing, +) +status.growth +``` + +## Model 2: let one object couple it automatically + +`ToyDegreeDaysCumulModel` publishes `TT`; `ToyDevelopmentModel` requires `TT`. +Because both applications target the same object and the producer is unique, +the scenario needs no explicit `inputs` wiring: + +```@example modeler_basic +one_object = CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec( + ToyDegreeDaysCumulModel(T_base=10.0); + name=:thermal_time, + on=One(scale=:Leaf), + ), + ModelSpec( + development; + name=:development, + on=One(scale=:Leaf), + ), + ), + environment=Atmosphere( + T=18.0, + Wind=1.0, + Rh=0.7, + duration=Day(1), + ), +) + +select( + DataFrame(Diagnostics.explain_bindings(one_object)), + :application_id, + :input, + :origin, + :source_application_ids, + :carrier_kind, +) +``` + +```@example modeler_basic +one_simulation = run!(one_object) +final_state(one_simulation) +``` + +PlantSimEngine creates `stress=1.0` from the model default and connects `TT` +through a shared `Ref`. The development kernel knows neither fact. + +## Model 3: reuse the kernel over several objects + +Change only application multiplicity from `One` to `Many`: + +```@example modeler_basic +several_objects = CompositeModel( + Object(:leaf_1; scale=:Leaf), + Object(:leaf_2; scale=:Leaf); + applications=( + ModelSpec( + ToyDegreeDaysCumulModel(T_base=10.0); + name=:thermal_time, + on=Many(scale=:Leaf), + ), + ModelSpec( + development; + name=:development, + on=Many(scale=:Leaf), + ), + ), + environment=Atmosphere( + T=18.0, + Wind=1.0, + Rh=0.7, + duration=Day(1), + ), +) + +final_state(run!(several_objects), Many(scale=:Leaf)) +``` + +Do not loop over objects inside `ToyDevelopmentModel.run!`. PlantSimEngine +compiles homogeneous execution targets and invokes the one-target kernel for +each selected object. + +## Model-author recap + +- **You implemented:** parameters, `Required`/`Default` inputs, output initial + state, and one-target arithmetic. +- **PlantSimEngine inferred:** default initialization, same-object coupling, + execution order, and repeated targets. +- **The scenario author keeps explicit:** objects, application names, + multiplicity, and environment data. +- **New API names:** `AbstractModel`, `inputs_`, `outputs_`, `Required`, + `Default`, and `run!`. diff --git a/docs/src/journeys/modelers/cross_object_values.md b/docs/src/journeys/modelers/cross_object_values.md new file mode 100644 index 000000000..4c3c764ff --- /dev/null +++ b/docs/src/journeys/modelers/cross_object_values.md @@ -0,0 +1,158 @@ +# Implement Cross-Object Values + +**New concept:** scalar and vector-like inputs use the same one-step kernel +contract. Object selection and topology remain scenario concerns. + +See [Build One Multiscale Plant](@ref) for the simulation-user construction +journey. + +The plain Julia block below is an excerpt from a shipped example whose +declarations and compositions are tested in `test/test-toy_models.jl`. + +## Model 4: consume one scalar from another object + +`ToyDevelopmentModel` already declares `stress=Default(1.0)`. A scenario may +replace that fallback with one live scalar from a soil object: + +```@example modeler_cross_object +using Dates, PlantMeteo, PlantSimEngine, DataFrames +using PlantSimEngine.Examples + +cross_object = CompositeModel( + Object(:soil; scale=:Soil, status=Status(stress=0.4)), + Object(:leaf; scale=:Leaf, status=Status(TT=10.0)); + applications=( + ModelSpec( + ToyDevelopmentModel(0.5); + name=:development, + on=One(scale=:Leaf), + inputs=( + :stress => One( + scale=:Soil, + within=SceneScope(), + var=:stress, + from_status=true, + ), + ), + ), + ), +) + +cross_simulation = run!(cross_object) +( + leaf=final_state(cross_simulation, :leaf), + binding=only(Diagnostics.explain_bindings(cross_object)), +) +``` + +The model kernel still reads only `status.stress`. It does not search for soil, +know object ids, or copy the scalar each step. The scenario's `One` selector +resolves a shared `Ref`. + +## Model 5: consume a vector-like multiscale value + +`ToyMaintenanceRespirationModel` runs once per leaf and publishes `Rm`. +`ToyPlantRmModel` declares one vector-like input and reduces it: + +```julia +PlantSimEngine.inputs_(::ToyPlantRmModel) = ( + Rm_organs=Required(AbstractVector{<:Real}), +) +PlantSimEngine.outputs_(::ToyPlantRmModel) = (Rm=-Inf,) + +function PlantSimEngine.run!( + model::ToyPlantRmModel, + status, + environment, + constants, + context, +) + status.Rm = sum(status.Rm_organs) + return nothing +end +``` + +The scenario decides that `Rm_organs` means all descendant leaf outputs: + +```@example modeler_cross_object +respiration = ToyMaintenanceRespirationModel( + 2.0, + 0.06, + 25.0, + 0.5, + 0.02, +) + +multiscale = CompositeModel( + Object(:plant; scale=:Plant), + Object( + :leaf_1; + scale=:Leaf, + parent=:plant, + status=Status(carbon_biomass=10.0), + ), + Object( + :leaf_2; + scale=:Leaf, + parent=:plant, + status=Status(carbon_biomass=20.0), + ); + applications=( + ModelSpec( + respiration; + name=:maintenance, + on=Many(scale=:Leaf), + ), + ModelSpec( + ToyPlantRmModel(); + name=:plant_maintenance, + on=One(scale=:Plant), + inputs=( + :Rm_organs => Many( + scale=:Leaf, + within=Subtree(), + application=:maintenance, + var=:Rm, + ), + ), + ), + ), + environment=Atmosphere( + T=25.0, + Wind=1.0, + Rh=0.7, + duration=Hour(1), + ), +) + +multiscale_simulation = run!(multiscale) +( + plant=final_state(multiscale_simulation, :plant), + leaves=final_state(multiscale_simulation, Many(scale=:Leaf)), +) +``` + +```@example modeler_cross_object +select( + DataFrame(Diagnostics.explain_bindings(multiscale)), + :application_id, + :input, + :source_ids, + :carrier_kind, + :copy_semantics, +) +``` + +The `Many` carrier is a live `RefVector`; the aggregation kernel operates on an +`AbstractVector` and stays independent of object count and identity. + +## Model-author recap + +- **You implemented:** scalar or vector-compatible input schemas and ordinary + one-step arithmetic. +- **PlantSimEngine inferred:** shared `Ref` and `RefVector` carriers plus + producer-before-consumer order. +- **The scenario author keeps explicit:** cross-object scope, multiplicity, + source application, and variable remapping. +- **New API names:** `One`, `Many`, `SceneScope`, `Subtree`, `from_status`, + `RefVector`, and `input_carrier`. diff --git a/docs/src/journeys/modelers/environment_and_cadence.md b/docs/src/journeys/modelers/environment_and_cadence.md new file mode 100644 index 000000000..043fdbe88 --- /dev/null +++ b/docs/src/journeys/modelers/environment_and_cadence.md @@ -0,0 +1,152 @@ +# Implement Environment And Cadence Traits + +**New concept:** model-authored runtime traits. Environment declarations name +the fields a kernel samples, while cadence and output-policy traits state when +the same kernel runs and how its values cross clocks. + +Simulation users configure providers in [Understand Environments](@ref) and +application clocks in [Give Models Different Cadences](@ref). + +## Model 6: declare sampled environment inputs + +`ToyMaintenanceRespirationModel` reads object state and sampled temperature. +The tested contract names that environment field explicitly: + +```julia +PlantSimEngine.inputs_(::ToyMaintenanceRespirationModel) = ( + carbon_biomass=Required(Real), +) +PlantSimEngine.environment_inputs_( + model::ToyMaintenanceRespirationModel, +) = (T=zero(model.T_ref),) +PlantSimEngine.outputs_(model::ToyMaintenanceRespirationModel) = ( + Rm=oftype(model.Rm_base, -Inf), +) +``` + +The kernel reads model parameters from `model`, object state from `status`, and +forcing from `environment`: + +```julia +function PlantSimEngine.run!( + model::ToyMaintenanceRespirationModel, + status, + environment, + constants, + context, +) + status.Rm = + status.carbon_biomass * + model.P_alive * + model.nitrogen_content * + model.Rm_base * + model.Q10^((environment.T - model.T_ref) / 10) + return nothing +end +``` + +```@example modeler_environment_time +using Dates, PlantMeteo, PlantSimEngine, DataFrames +using PlantSimEngine.Examples + +respiration = ToyMaintenanceRespirationModel( + 2.0, + 0.06, + 25.0, + 0.5, + 0.02, +) +model = CompositeModel( + Object( + :leaf; + scale=:Leaf, + status=Status(carbon_biomass=10.0), + ); + applications=( + ModelSpec( + respiration; + name=:maintenance, + on=One(scale=:Leaf), + ), + ), + environment=Atmosphere( + T=25.0, + Wind=1.0, + Rh=0.7, + duration=Hour(1), + ), +) + +( + declared=PlantSimEngine.environment_inputs_(respiration), + final=final_state(run!(model)), +) +``` + +The model does not name a provider or inspect raw weather storage. +`Environment(...)` remains scenario configuration. + +## Model 7: declare cadence and output semantics + +`ToyDailyDevelopmentModel` accumulates one increment whenever it runs. Its +model-level traits say “every 24 simulation steps, starting at step 1” and +“consumers may hold the last daily value between publications”: + +```julia +PlantSimEngine.timespec(::Type{<:ToyDailyDevelopmentModel}) = + ClockSpec(24.0, 1.0) +PlantSimEngine.output_policy( + ::Type{<:ToyDailyDevelopmentModel}, +) = (daily_growth=HoldLast(),) +``` + +`ClockSpec` is expressed in simulation steps. Use +`ModelSpec(...; every=Day(1))` when a scenario should express a +duration-relative cadence or override the model default. + +```@example modeler_environment_time +daily = ToyDailyDevelopmentModel(2.0) +daily_model = CompositeModel( + Object(:plant; scale=:Plant); + applications=( + ModelSpec( + daily; + name=:daily_development, + on=One(scale=:Plant), + ), + ), + environment=[ + (duration=Hour(1),) + for _ in 1:25 + ], +) + +daily_simulation = + run!(daily_model; steps=25, outputs=:all) +( + traits=( + clock=PlantSimEngine.timespec(daily), + outputs=PlantSimEngine.output_policy(daily), + ), + schedule=DataFrame(Diagnostics.explain_schedule(daily_model)), + final=final_state(daily_simulation), + retained=DataFrame( + Diagnostics.explain_outputs(daily_simulation), + ), +) +``` + +The model runs at steps 1 and 25. A downstream application can override +`HoldLast` with an explicit selector policy when its scientific interpretation +requires `Integrate`, `Aggregate`, or `Interpolate`. + +## Model-author recap + +- **You implemented:** declared environment reads, a model default clock, and + per-output temporal meaning. +- **PlantSimEngine inferred:** environment validation/sampling, execution + steps, and the default cross-clock policy. +- **The scenario author keeps explicit:** concrete environment provider, + simulation base step, cadence overrides, and input-policy overrides. +- **New API names:** `environment_inputs_`, `timespec`, `ClockSpec`, + `output_policy`, and `HoldLast`. diff --git a/docs/src/journeys/modelers/hard_dependencies.md b/docs/src/journeys/modelers/hard_dependencies.md new file mode 100644 index 000000000..769f7c6d1 --- /dev/null +++ b/docs/src/journeys/modelers/hard_dependencies.md @@ -0,0 +1,129 @@ +# Implement A Hard Dependency + +**New concept:** a process-level hard dependency, used only when the parent +kernel must control another model's execution. Value dependencies should stay +in `inputs_`. + +Simulation users wire and inspect advanced calls in +[Control Advanced Execution](@ref). + +The plain Julia blocks below are excerpts from the shipped, tested +`ToySelectiveCallControllerModel`. + +## Model 8: declare and execute the call + +`ToySelectiveCallControllerModel` declares a reusable default by process, +scale, and relative scope. It cannot know future application names: + +```julia +PlantSimEngine.dep(::ToySelectiveCallControllerModel) = ( + readers=Call(Many( + scale=:Leaf, + process=:toy_environment_reader, + within=Subtree(), + )), +) +``` + +Inside `run!`, the controller inspects the vector-like collection, restricts +the declared call by object id, runs trials without publication, and publishes +one accepted execution: + +```julia +targets = call_targets(context, :readers) +selected = only(call_targets( + context, + :readers; + objects=(ObjectId(model.selected_object),), +)) + +for temperature in model.trial_temperatures + run_call!( + selected; + sampled_environment=(T=temperature,), + publish=false, + ) +end +run_call!( + selected; + sampled_environment=(T=model.accepted_temperature,), + publish=true, +) +``` + +The source excerpt is exercised by the example-model contract suite. Build a +scenario without a `calls` keyword to use that model default: + +```@example modeler_hard_dependency +using PlantSimEngine, DataFrames +using PlantSimEngine.Examples + +environment = ToySpatialEnvironment( + Dict( + :sun => (T=26.0,), + :shade => (T=18.0,), + ); + step_seconds=3600.0, +) +model = CompositeModel( + Object(:plant; scale=:Plant), + Object( + :sun_leaf; + scale=:Leaf, + parent=:plant, + geometry=(cell=:sun,), + ), + Object( + :shade_leaf; + scale=:Leaf, + parent=:plant, + geometry=(cell=:shade,), + ); + applications=( + ModelSpec( + ToyEnvironmentReaderModel(); + name=:reader, + on=Many(scale=:Leaf), + environment=Environment(backend=environment), + ), + ModelSpec( + ToySelectiveCallControllerModel( + (28.0, 31.0), + 22.0; + selected_object=:sun_leaf, + ); + name=:controller, + on=One(scale=:Plant), + ), + ), +) + +DataFrame(Diagnostics.explain_calls(model)) +``` + +```@example modeler_hard_dependency +simulation = run!(model; outputs=:all) +( + controller=final_state(simulation, :plant), + leaves=final_state(simulation, Many(scale=:Leaf)), + publications=filter( + row -> row.application_id == :reader, + DataFrame(Diagnostics.explain_outputs(simulation)), + ), +) +``` + +`origin=:model_default` identifies the `dep(model)` contract. A concrete +scenario can replace it with `ModelSpec(...; calls=...)` when application +identity or target selection differs. + +## Model-author recap + +- **You implemented:** a process-level `Call` requirement and explicit + execution inside the parent kernel. +- **PlantSimEngine inferred:** call-only scheduling, resolved targets, nested + context, and publication boundaries. +- **The scenario author keeps explicit:** application overrides and any + architecture-specific target choice. +- **New API names:** `dep`, `Call`, `call_targets`, `CallTargets`, + `run_call!`, `sampled_environment`, and `publish`. diff --git a/docs/src/journeys/modelers/mutable_environment.md b/docs/src/journeys/modelers/mutable_environment.md new file mode 100644 index 000000000..1034f3c19 --- /dev/null +++ b/docs/src/journeys/modelers/mutable_environment.md @@ -0,0 +1,125 @@ +# Implement A Mutable Environment Controller + +**New concept:** accepted mutable environment state. A controller declares +which variables it may commit, evaluates typed trial states, and commits one +accepted state explicitly. + +Simulation users first encounter this workflow in +[Modify The Environment](@ref). Backend packages implement the separate +[Environment Backend Extensions](@ref) contract. + +The plain Julia blocks below are excerpts from the shipped, tested +`ToyEnvironmentControllerModel`. + +## Model 9: declare commit permission + +`ToyEnvironmentControllerModel` declares its reader hard dependency and the +environment variable it may commit: + +```julia +PlantSimEngine.dep(::ToyEnvironmentControllerModel) = ( + reader=Call(One(process=:toy_environment_reader)), +) +PlantSimEngine.environment_outputs_( + model::ToyEnvironmentControllerModel, +) = (T=zero(model.accepted_temperature),) +``` + +Its tested kernel keeps trial, commit, and accepted publication separate: + +```julia +trial_environment = (T=model.trial_temperature,) +trial_target = only(run_call!( + context, + :reader; + environment=trial_environment, + publish=false, +)) + +accepted_environment = (T=model.accepted_temperature,) +commit_environment!(context, accepted_environment) +accepted_target = only(run_call!( + context, + :reader; + environment=accepted_environment, + publish=true, +)) +``` + +`environment_outputs_` is commit permission, not object-status output. +PlantSimEngine validates that the accepted state provides every declared +variable before invoking the backend through the controller's compiled handle. + +## Compose the controller + +The reader needs only a provider. The controller additionally receives +`sink=:cells`; the backend defines what that sink means: + +```@example modeler_mutable_environment +using PlantSimEngine, DataFrames +using PlantSimEngine.Examples + +environment = ToySpatialEnvironment( + Dict(:canopy => (T=20.0,)); + step_seconds=3600.0, +) +model = CompositeModel( + Object( + :leaf; + scale=:Leaf, + geometry=(cell=:canopy,), + ); + applications=( + ModelSpec( + ToyEnvironmentReaderModel(); + name=:reader, + on=One(scale=:Leaf), + environment=Environment(backend=environment), + ), + ModelSpec( + ToyEnvironmentControllerModel(30.0, 22.0); + name=:controller, + on=One(scale=:Leaf), + environment=Environment( + backend=environment, + sink=:cells, + ), + ), + ), +) + +( + call=DataFrame(Diagnostics.explain_calls(model)), + environment=DataFrame( + Diagnostics.explain_environment_bindings(model), + ), +) +``` + +```@example modeler_mutable_environment +simulation = run!(model; outputs=:all) +( + final=final_state(simulation), + committed=environment.cells[:canopy], + publications=filter( + row -> row.application_id == :reader, + DataFrame(Diagnostics.explain_outputs(simulation)), + ), +) +``` + +The rejected `T=30` trial mutates only the reader's trial status. The accepted +`T=22` state is committed once and produces the reader's only retained sample. +If the controller itself runs as an unpublished ancestor call, PlantSimEngine +also suppresses its descendant publications and environment writes. + +## Model-author recap + +- **You implemented:** declared commit variables, typed trial construction, + acceptance logic, explicit commit, and one accepted publication. +- **PlantSimEngine inferred:** permission validation, backend/handle routing, + nested trial suppression, and retained output history. +- **The scenario author keeps explicit:** provider, commit sink, concrete + backend, and any hard-call override. +- **New API names:** `environment_outputs_`, `commit_environment!`, + `Environment`, `environment`, and `publish`. diff --git a/docs/src/journeys/users/advanced_execution.md b/docs/src/journeys/users/advanced_execution.md new file mode 100644 index 000000000..aca6f6388 --- /dev/null +++ b/docs/src/journeys/users/advanced_execution.md @@ -0,0 +1,214 @@ +# Control Advanced Execution + +## New concept: parent-controlled execution and explicit publication + +Most coupling should remain a value dependency through `inputs`. Use a hard +call only when a parent algorithm must decide whether, when, or how often +another model runs—for example, while iterating toward an accepted leaf +temperature. + +## Declare parent-controlled targets + +Start with one plant and two leaves. The reader application is selected by the +controller's `calls` declaration, so it is call-only rather than independently +scheduled: + +```@example journey_advanced_execution +using PlantSimEngine, DataFrames +using PlantSimEngine.Examples + +environment = ToySpatialEnvironment( + Dict( + :sun => (T=26.0,), + :shade => (T=18.0,), + ); + step_seconds=3600.0, +) + +model = CompositeModel( + Object(:plant; scale=:Plant, kind=:plant), + Object( + :sun_leaf; + scale=:Leaf, + kind=:leaf, + parent=:plant, + geometry=(cell=:sun,), + ), + Object( + :shade_leaf; + scale=:Leaf, + kind=:leaf, + parent=:plant, + geometry=(cell=:shade,), + ); + applications=( + ModelSpec( + ToyEnvironmentReaderModel(); + name=:reader, + on=Many(scale=:Leaf), + environment=Environment(backend=environment), + ), + ModelSpec( + ToySelectiveCallControllerModel( + (28.0, 31.0), + 22.0; + selected_object=:sun_leaf, + ); + name=:controller, + on=One(scale=:Plant), + calls=( + :readers => Many( + scale=:Leaf, + within=Subtree(), + application=:reader, + ), + ), + ), + ), +) + +select( + DataFrame(Diagnostics.explain_calls(model)), + :application_id, + :call, + :callee_application_ids, + :callee_object_ids, + :publication_policy, +) +``` + +`run_call!(context, :readers)` executes every resolved target and returns a +vector-like `CallTargets` collection. `One` still returns a collection of one; +`OptionalOne` returns zero or one; `Many` returns zero or more. + +## Inspect, select, iterate, then publish once + +`ToySelectiveCallControllerModel` needs different treatment per target, so its +kernel first calls `call_targets(context, :readers)` without executing +anything. It records the total, restricts the same declared call to +`:sun_leaf`, runs two temperature trials, and accepts one result: + +```@example journey_advanced_execution +function run_selected_trials!( + target, + trial_temperatures, + accepted_temperature, +) + for temperature in trial_temperatures + run_call!( + target; + sampled_environment=(T=temperature,), + publish=false, + ) + end + run_call!( + target; + sampled_environment=(T=accepted_temperature,), + publish=true, + ) +end +``` + +`publish=false` is the default. Trials may update the called model's current +status for convergence checks, but they neither append output samples nor +commit mutable environment state. Publish exactly the accepted execution. + +```@example journey_advanced_execution +simulation = run!(model; outputs=:all) +( + controller=final_state(simulation, :plant), + leaves=final_state(simulation, Many(scale=:Leaf)), +) +``` + +The controller resolved two targets but selected only `:sun_leaf`. Object +selection uses `call_targets(context, name; objects=(ObjectId(:sun_leaf),))`; +it does not depend on iteration order. Two trials +left no history; its accepted call published once, while `:shade_leaf` was +never executed: + +```@example journey_advanced_execution +filter( + row -> row.application_id == :reader, + DataFrame(Diagnostics.explain_outputs(simulation)), +) +``` + +Use `run_call!(context, name; environment=trial_state)` when every target +should sample the same provider-aware trial state through its own compiled +handle. Use `call_targets` and `run_call!(target; sampled_environment=...)` +for selection, custom order, or distinct already-sampled environments. + +## Order intentional duplicate writers + +One canonical variable normally has one writer. Two applications that both +claim `stock` therefore fail compilation unless their relationship is +intentional and ordered. `Updates` makes that ownership explicit: + +```@example journey_advanced_execution +writer_model = CompositeModel( + Object(:reserve; scale=:Organ); + applications=( + ModelSpec( + ToyStockWriterModel(4); + name=:initial_stock, + on=One(scale=:Organ), + ), + ModelSpec( + ToyStockWriterModel(8); + name=:adjusted_stock, + on=One(scale=:Organ), + updates=Updates(:stock; after=:initial_stock), + ), + ModelSpec( + ToyStockWriterModel(99); + name=:alternative_stock, + on=One(scale=:Organ), + output_routing=(stock=:stream_only,), + ), + ), +) + +select( + DataFrame(Diagnostics.explain_writers(writer_model)), + :object_id, + :variable, + :application_ids, + :update_application_ids, + :update_after, +) +``` + +`initial_stock` owns the first canonical write and `adjusted_stock` explicitly +updates it afterward. The alternative value is useful as a retained comparison +but must not replace canonical status, so `output_routing` marks it +`:stream_only`. + +```@example journey_advanced_execution +writer_simulation = run!(writer_model; outputs=:all) +( + canonical_stock=final_state(writer_simulation).stock, + published=collect_outputs( + writer_simulation, + :reserve, + :stock; + sink=nothing, + ), +) +``` + +The canonical result is `8`; the three application-specific streams retain +`4`, `8`, and `99`. A stream-only output is not a fallback writer and is not +selected by an application-free `OutputRequest`; request its application +explicitly when retaining only selected outputs. + +## Page recap + +- **You added:** one declared hard call, selective trials, one accepted + publication, explicit update ordering, and one stream-only alternative. +- **PlantSimEngine inferred:** the call-only schedule, concrete targets, + canonical writer ownership, and update edge. +- **You keep explicit:** when each target runs, `publish`, per-target forcing, + intentional duplicate writers, and non-canonical streams. +- **New API names:** `calls`, `call_targets`, `run_call!`, `CallTargets`, + `Updates`, `output_routing`, and `:stream_only`. diff --git a/docs/src/journeys/users/cadences.md b/docs/src/journeys/users/cadences.md new file mode 100644 index 000000000..b5d0184fa --- /dev/null +++ b/docs/src/journeys/users/cadences.md @@ -0,0 +1,203 @@ +# Give Models Different Cadences + +## New concept: application clocks and temporal input policies + +Running a whole composite over many timesteps was introduced on the first +journey. This page changes one thing: applications no longer all run at the +environment base step. + +## Hold a daily state for an hourly model + +Reuse the thermal-time, LAI, and light chain. The environment advances hourly; +thermal time and LAI run daily; light interception runs hourly. `HoldLast` +makes each hourly light execution read the latest published daily LAI. + +```@example journey_cadences +using PlantSimEngine, Dates, DataFrames +using PlantSimEngine.Examples + +hourly_forcing = [ + (T=20.0, Ri_PAR_f=300.0, duration=Hour(1)) + for _ in 1:25 +] + +model = CompositeModel( + Object(:plant; scale=:Plant, kind=:plant); + applications=( + ModelSpec( + ToyDegreeDaysCumulModel(); + name=:degree_days, + on=One(scale=:Plant), + every=Day(1), + ), + ModelSpec( + ToyLAIModel(); + name=:lai, + on=One(scale=:Plant), + every=Day(1), + ), + ModelSpec( + Beer(0.6); + name=:light, + on=One(scale=:Plant), + inputs=( + :LAI => One( + within=Self(), + application=:lai, + var=:LAI, + policy=HoldLast(), + window=Day(1), + ), + ), + every=Hour(1), + ), + ), + environment=hourly_forcing, +) + +simulation = run!(model; steps=25, outputs=:all) +``` + +The schedule reports physical cadence in seconds and in base steps: + +```@example journey_cadences +select( + DataFrame(Diagnostics.explain_schedule(model)), + :application_id, + :dt_seconds, + :dt_steps, +) +``` + +The temporal binding is explicit even though the producer and consumer share +an object: + +```@example journey_cadences +select( + DataFrame(Diagnostics.explain_bindings(model)), + :application_id, + :input, + :policy, + :window, + :carrier_kind, +) +``` + +The daily applications publish at steps 1 and 25; the hourly application +publishes on all 25 steps: + +```@example journey_cadences +select( + DataFrame(Diagnostics.explain_outputs(simulation)), + :application_id, + :variable, + :nsamples, +) +``` + +`HoldLast` is appropriate because LAI is a state: between daily updates, its +latest value remains meaningful. + +## Integrate a rate into an amount + +`Integrate` has a different physical meaning. If a leaf publishes a constant +rate in units per second, integrating 24 hourly samples produces a daily +amount. The consumer below sums the independently integrated amounts from two +leaves. + +```@example journey_cadences +PlantSimEngine.@process "cadence_hourly_flux" verbose = false +PlantSimEngine.@process "cadence_daily_amount" verbose = false + +struct CadenceHourlyFlux <: AbstractCadence_Hourly_FluxModel end +struct CadenceDailyAmount <: AbstractCadence_Daily_AmountModel end + +PlantSimEngine.inputs_(::CadenceHourlyFlux) = (rate=Required(Real),) +PlantSimEngine.outputs_(::CadenceHourlyFlux) = (flux=0.0,) +PlantSimEngine.run!( + ::CadenceHourlyFlux, + status, + environment, + constants, + context, +) = (status.flux = status.rate) + +PlantSimEngine.inputs_(::CadenceDailyAmount) = ( + leaf_amounts=Required(AbstractVector{<:Real}), +) +PlantSimEngine.outputs_(::CadenceDailyAmount) = (amount=0.0,) +PlantSimEngine.run!( + ::CadenceDailyAmount, + status, + environment, + constants, + context, +) = (status.amount = sum(status.leaf_amounts)) +``` + +```@example journey_cadences +flux_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( + CadenceHourlyFlux(); + name=:hourly_flux, + on=Many(scale=:Leaf), + every=Hour(1), + ), + ModelSpec( + CadenceDailyAmount(); + name=:daily_amount, + on=One(scale=:Plant), + inputs=( + :leaf_amounts => Many( + scale=:Leaf, + within=Subtree(), + application=:hourly_flux, + var=:flux, + policy=Integrate(), + window=Day(1), + ), + ), + every=Day(1), + ), + ), + environment=[(duration=Hour(1),) for _ in 1:25], +) + +flux_simulation = run!(flux_model; steps=25) +final_state(flux_simulation, One(scale=:Plant)).amount +``` + +The result is `(1 + 2) × 24 × 3600 = 259200` rate-seconds. Use +`Aggregate(reducer)` instead when the desired quantity is a mean, minimum, +maximum, or another reduction of observations rather than a time integral. + +There is no same-step feedback cycle in either example, so +`PreviousTimeStep` is not needed. It should be introduced only when a real +scientific dependency intentionally reads the preceding step to break such a +cycle. + +## Page recap + +- **You added:** daily and hourly application clocks, `HoldLast` for a state, + and then `Integrate` for a rate. +- **PlantSimEngine inferred:** the base-step ratios, publication schedule, and + bounded temporal storage needed by the consumers. +- **You keep explicit:** each application cadence, the physical meaning of its + temporal policy, and the integration window. +- **New API names:** `every`, `HoldLast`, `Integrate`, `Aggregate`, + `window`, `Diagnostics.explain_schedule`, and + `Diagnostics.explain_outputs`. diff --git a/docs/src/journeys/users/environments.md b/docs/src/journeys/users/environments.md new file mode 100644 index 000000000..d3ab194d7 --- /dev/null +++ b/docs/src/journeys/users/environments.md @@ -0,0 +1,166 @@ +# Understand Environments + +## New concept: declared sampling from global and spatial sources + +The first simulation used a weather file as supplied forcing. This page now +makes that contract explicit. A model declares the names it reads from its +model-facing environment: + +```@example journey_environments +using PlantSimEngine, Dates, DataFrames +using PlantSimEngine.Examples + +( + degree_days=PlantSimEngine.environment_inputs_( + ToyDegreeDaysCumulModel(), + ), + light=PlantSimEngine.environment_inputs_(Beer(0.6)), +) +``` + +`ToyDegreeDaysCumulModel` reads `environment.T`; `Beer` reads +`environment.Ri_PAR_f`. These are not status inputs and are not outputs owned +by the target object. + +## Global sampling and source names + +The source does not need to use the model-facing names. Here a global provider +has `air_temperature` and `incident_par`; `Environment(...; sources=...)` +remaps them for the two model applications. + +```@example journey_environments +forcing = ( + air_temperature=20.0, + incident_par=300.0, + duration=Day(1), +) + +global_model = CompositeModel( + Object(:plant; scale=:Plant, kind=:plant); + applications=( + ModelSpec( + ToyDegreeDaysCumulModel(); + name=:degree_days, + on=One(scale=:Plant), + environment=Environment( + provider=:global, + sources=(T=:air_temperature,), + ), + ), + ModelSpec( + ToyLAIModel(); + name=:lai, + on=One(scale=:Plant), + ), + ModelSpec( + Beer(0.6); + name=:light, + on=One(scale=:Plant), + environment=Environment( + provider=:global, + sources=(Ri_PAR_f=:incident_par,), + ), + ), + ), + environment=forcing, +) + +validate_environment_inputs(global_model) +global_simulation = run!(global_model) +global_state = final_state(global_simulation) +(TT_cu=global_state.TT_cu, LAI=global_state.LAI, aPPFD=global_state.aPPFD) +``` + +The environment diagnostic distinguishes the variables seen by each model from +the actual source names: + +```@example journey_environments +select( + DataFrame(Diagnostics.explain_environment_bindings(global_model)), + :application_id, + :object_id, + :required_inputs, + :source_inputs, + :handle, +) +``` + +Global sampling has no spatial handle. The forcing above is intentionally +strict: apart from timeline `duration`, it exposes only the two remapped source +variables. Removing either source makes `validate_environment_inputs` fail +before simulation. + +## Spatial sampling + +Spatial backends keep the same model-facing declaration. They additionally +compile an opaque handle for each application/object target. The small +`ToySpatialEnvironment` example maps object geometry to either a sunny or +shaded cell: + +```@example journey_environments +spatial_environment = ToySpatialEnvironment( + Dict( + :sun => (Ri_PAR_f=400.0,), + :shade => (Ri_PAR_f=100.0,), + ); + step_seconds=3600.0, +) + +spatial_model = CompositeModel( + Object( + :sun_leaf; + scale=:Leaf, + kind=:leaf, + geometry=(cell=:sun,), + status=Status(LAI=2.0), + ), + Object( + :shade_leaf; + scale=:Leaf, + kind=:leaf, + geometry=(cell=:shade,), + status=Status(LAI=2.0), + ); + applications=( + ModelSpec( + Beer(0.6); + name=:light, + on=Many(scale=:Leaf), + environment=Environment(backend=spatial_environment), + ), + ), +) + +spatial_simulation = run!(spatial_model) +spatial_states = final_state(spatial_simulation, Many(scale=:Leaf)) +Dict(id => state.aPPFD for (id, state) in spatial_states) +``` + +The one `Many` application retains two distinct compiled handles: + +```@example journey_environments +select( + DataFrame(Diagnostics.explain_environment_bindings(spatial_model)), + :application_id, + :object_id, + :geometry_source, + :handle, +) +``` + +The scientific `Beer` kernel is unchanged. It sees only +`environment.Ri_PAR_f`; the backend owns the meaning of each handle. Backend +authors can inspect the implementation of `ToySpatialEnvironment` in the +environment extension reference. + +## Page recap + +- **You added:** explicit environment declarations, global source remapping, + and then a spatial backend with object geometry. +- **PlantSimEngine inferred:** global sampling, validation of required source + names, and one cached spatial handle per application/object target. +- **You keep explicit:** model-facing environment names, scenario source + remaps, provider/backend choice, and geometry used by a spatial backend. +- **New API names:** `environment_inputs_`, `Environment`, + `validate_environment_inputs`, `ToySpatialEnvironment`, and + `Diagnostics.explain_environment_bindings`. diff --git a/docs/src/journeys/users/maespa_synthesis.md b/docs/src/journeys/users/maespa_synthesis.md new file mode 100644 index 000000000..3b298f3a1 --- /dev/null +++ b/docs/src/journeys/users/maespa_synthesis.md @@ -0,0 +1,242 @@ +# MAESPA-Style Synthesis + +## New concept: synthesis without another runtime mechanism + +This page is an integrated reference, not an onboarding example. It combines +the ideas developed independently in the earlier journeys into a small +MAESPA-style stand: two species, five leaves, hourly canopy and soil exchange, +daily allocation and LAI, iterative leaf calls, and accepted mutable canopy +air. + +If any individual mechanism is unfamiliar, follow its focused link in +[How the pieces compose](@ref) before reading the implementation. + +## Run the reference case + +The complete, tested source lives in +`examples/maespa_model_example.jl`. Run 25 hours so both hourly and daily +applications cross a day boundary: + +```@example journey_maespa_synthesis +using PlantSimEngine, DataFrames + +include(joinpath( + pkgdir(PlantSimEngine), + "examples", + "maespa_model_example.jl", +)) + +result = run_maespa_example(; nhours=25, check=true) +simulation = result.simulation +model = result.model +nothing +``` + +The model contains one scene, one soil object, and two template instances with +different species parameters and leaf counts: + +```@example journey_maespa_synthesis +( + instances=DataFrame(Diagnostics.explain_instances(model)), + plants=length(model_objects(model; scale=:Plant)), + leaves=length(model_objects(model; scale=:Leaf)), + species_A=length(model_objects(model; scale=:Leaf, species=:A)), + species_B=length(model_objects(model; scale=:Leaf, species=:B)), +) +``` + +## Inspect the compiled architecture + +The execution schedule makes the two cadences and parent-controlled +applications visible. Leaf energy balance and soil water are call-only under +the scene controller; allocation and LAI run daily: + +```@example journey_maespa_synthesis +schedule = DataFrame(Diagnostics.explain_schedule(result.compiled)) +select( + filter( + row -> row.application_id in ( + :scene_eb, + :soil_water, + :lai_dynamic, + :plant_A__energy_balance, + :plant_A__allocation, + :plant_B__allocation, + ), + schedule, + ), + :application_id, + :root_scheduled, + :manual_call_only, + :dt_steps, +) +``` + +The scene energy-balance application resolves all five leaves through one +`Many` hard call and the soil through one `One` hard call: + +```@example journey_maespa_synthesis +calls = DataFrame(Diagnostics.explain_calls(result.compiled)) +select( + filter(row -> row.application_id == :scene_eb, calls), + :call, + :callee_application_ids, + :callee_object_ids, + :publication_policy, +) +``` + +Each plant allocation application receives a live vector of only its own +descendant leaves. The scene receives stand-wide vectors and one scalar soil +potential: + +```@example journey_maespa_synthesis +bindings = DataFrame(Diagnostics.explain_bindings(result.compiled)) +select( + filter( + row -> ( + row.application_id in ( + :plant_A__allocation, + :plant_B__allocation, + ) && row.input == :leaf_carbon + ) || ( + row.application_id == :scene_eb && + row.input in (:leaf_areas, :psi_soil) + ), + bindings, + ), + :application_id, + :input, + :source_ids, + :carrier_kind, + :copy_semantics, +) +``` + +## Follow trial canopy air to its accepted state + +The scene controller reads above-canopy `:forcing`, iterates leaf models +against typed trial canopy air with `publish=false`, commits the converged +state to `sink=:canopy`, and then publishes one accepted leaf execution. Leaf +applications read the committed `:canopy` provider. Those routes are compiled +into opaque handles: + +```@example journey_maespa_synthesis +environment_bindings = DataFrame( + Diagnostics.explain_environment_bindings(result.environment), +) +select( + filter( + row -> row.application_id in ( + :scene_eb, + :plant_A__energy_balance, + :plant_B__energy_balance, + ), + environment_bindings, + ), + :application_id, + :object_id, + :handle, + :required_inputs, + :produced_outputs, +) +``` + +`MaespaSingleLayerEnvironment` is intentionally a one-layer canopy backend. +Its handle still separates forcing, canopy, and commit-sink routes per +application/object. A voxel or multilayer backend can replace it without +changing the model-facing environment contract; the two-cell proof is in +[Modify The Environment](@ref), and backend implementation belongs in +[Environment Backend Extensions](@ref). + +## Check the scientific handoffs + +The final snapshots expose canonical state independently of retained history: + +```@example journey_maespa_synthesis +scene = final_state(simulation, :model) +soil = final_state(simulation, :soil) +plants = final_state(simulation, Many(scale=:Plant)) + +( + lai=scene.lai, + canopy_temperature=scene.canopy_tair, + transpiration=scene.scene_transpiration, + soil_water_potential=soil.psi_soil, + daily_growth=Dict( + id => state.daily_growth + for (id, state) in plants + ), +) +``` + +Retained output counts confirm the cadence boundary: hourly scene and leaf +variables have 25 samples, while daily LAI and allocation variables have two: + +```@example journey_maespa_synthesis +output_summary = DataFrame(Diagnostics.explain_outputs(simulation)) +select( + filter( + row -> ( + row.object_id == :model && + row.variable in (:scene_transpiration, :lai) + ) || ( + row.object_id in (:plant_A, :plant_B) && + row.variable == :daily_growth + ) || ( + row.object_id == :plant_A_leaf_1 && + row.variable == :λE + ), + output_summary, + ), + :application_id, + :object_id, + :variable, + :nsamples, +) +``` + +## How the pieces compose + +| Construct in this synthesis | Role here | Focused journey | +|---|---|---| +| `CompositeModelTemplate` and two `ObjectInstance`s | Reuse one species-specific application set across several plants | [Instantiate Several Plants](@ref) | +| Scene, plant, internode, leaf, and soil objects | Represent one registry without prescribing plant architecture | [Build One Multiscale Plant](@ref) | +| Scalar and `Many` live-reference bindings | Couple soil-to-scene and leaf-to-plant/scene values | [Build One Multiscale Plant](@ref) | +| Hourly and daily applications with `HoldLast` | Keep canopy exchange and allocation on scientific cadences | [Give Models Different Cadences](@ref) | +| Forcing and canopy providers with compiled handles | Sample global forcing and committed canopy state through one contract | [Understand Environments](@ref) | +| Typed trials and explicit accepted commit | Iterate canopy air without publishing rejected states | [Modify The Environment](@ref) | +| Nested hard calls and accepted publication | Let scene energy balance control leaf and soil execution | [Control Advanced Execution](@ref) | +| `Simulation`, final state, and retained streams | Separate current canonical state from requested history | [Couple Models On One Object](@ref) | + +This 25-hour reference keeps plant topology fixed because organogenesis is not +part of its scientific question. A growth model can add, reparent, or remove +organs through the same registry and refresh machinery; that independent +lifecycle is demonstrated in [Modify Plant Structure](@ref). Keeping it out of +this synthesis prevents canopy iteration, daily allocation, and topology +mutation from becoming one inseparable example. + +## Reference invariants + +The automated example test verifies the important handoffs rather than exact +floating-point trajectories: + +- the stand contains two isolated instances and five correctly routed leaves; +- plant allocation vectors contain only descendant leaves; +- the scene call resolves every leaf and the shared soil application; +- hourly and daily output counts match their cadences; +- accepted canopy air is committed separately from above-canopy forcing; +- leaf fluxes are finite and aggregate consistently at scene scale; +- both species grow, while their parameterized allocations remain distinct. + +## Page recap + +- **You added:** no new primitive; you assembled the earlier topology, + cadence, hard-call, environment, and output mechanisms into one stand. +- **PlantSimEngine inferred:** application order, plant-local bindings, + concrete call targets, environment handles, and the hourly/daily schedule. +- **You keep explicit:** species parameters, topology, scientific iteration, + accepted state, output requests, and the invariants used to validate the + result. +- **New API names:** none. Every API in this synthesis was introduced on a + focused earlier journey. diff --git a/docs/src/journeys/users/mental_model.md b/docs/src/journeys/users/mental_model.md new file mode 100644 index 000000000..ffec0a6b2 --- /dev/null +++ b/docs/src/journeys/users/mental_model.md @@ -0,0 +1,55 @@ +# A Mental Model For PlantSimEngine + +## New concept: composition + +PlantSimEngine is a framework for composing and running scientific models. It +does not prescribe a plant architecture, and it is not itself a library of +crop, tree, or organ equations. Model packages provide those equations; +PlantSimEngine connects them to simulated entities, orders their execution, and +manages time, environments, and retained results. + +Seven ideas are enough to read a PlantSimEngine simulation: + +| Idea | Meaning | +|:--|:--| +| **Process** | A scientific responsibility, such as thermal time or light interception | +| **Model** | One reusable implementation of a process | +| **Application** | One configured use of a model in a simulation | +| **Object** | A simulated entity with stable identity, such as a scene, plant, leaf, or soil layer | +| **Status** | The current values owned by an object | +| **Environment** | Values sampled from outside object status, such as weather or microclimate | +| **Simulation** | A running timeline, including the live model, current step, and any retained output streams | + +The distinction between a model and an application is important. A model +author writes an equation once. A simulation author can then apply it to one +whole plant, every leaf, selected soil layers, or several named groups without +putting an object loop inside the equation. + +Objects are equally general. Scales and parent/child links describe the +topology chosen by the simulation author; PlantSimEngine does not require a +particular hierarchy. A simple simulation can have one object. A detailed one +can have scenes, several plants, organs, voxels, and shared resources. + +Values reach a model in two ways: + +- status inputs come from the target object or from outputs of other model + applications; +- environment inputs are sampled from the environment selected for that + application and object. + +Before running, PlantSimEngine compiles applications into concrete +application/object targets, resolves value connections, and determines a valid +execution order. During the timestep loop, model kernels work with their own +parameters, the resolved status view, the sampled environment, constants, and +a runtime context. + +## Page recap + +- **You added:** no configuration yet—only the vocabulary used by every later + journey. +- **PlantSimEngine infers:** application order and unambiguous value + connections once a simulation is assembled. +- **You keep explicit:** scientific equations, object topology, ambiguous + cross-object connections, time policies, and requested output history. +- **New API names:** none yet. The next page introduces `CompositeModel`, + `run!`, `Simulation`, `final_state`, and `collect_outputs`. diff --git a/docs/src/journeys/users/mutable_environments.md b/docs/src/journeys/users/mutable_environments.md new file mode 100644 index 000000000..6840a6214 --- /dev/null +++ b/docs/src/journeys/users/mutable_environments.md @@ -0,0 +1,181 @@ +# Modify The Environment + +## New concept: trial state versus accepted state + +The previous environment journey sampled read-only global and spatial values. +Now a controller evaluates one typed trial state, accepts a different state, +and commits it explicitly. + +Start with one cell. `ToyEnvironmentReaderModel` declares `T` as an environment +input. The controller declares `T` as an environment output: this is permission +to commit that variable, not an ordinary object-status output. + +```@example journey_mutable_environment +using PlantSimEngine, DataFrames +using PlantSimEngine.Examples + +( + reader_inputs=PlantSimEngine.environment_inputs_( + ToyEnvironmentReaderModel(), + ), + controller_commit_permissions=PlantSimEngine.environment_outputs_( + ToyEnvironmentControllerModel(30.0, 22.0), + ), +) +``` + +The controller's kernel uses the current typed trial-state path. A trial call +changes the callee status for inspection but does not publish output history or +commit backend state: + +```@example journey_mutable_environment +function run_trial!(context, trial_environment) + return only(run_call!( + context, + :reader; + environment=trial_environment, + publish=false, + )) +end +``` + +Accepted state is explicit and separate: + +```@example journey_mutable_environment +function commit_and_publish!(context, accepted_environment) + commit_environment!(context, accepted_environment) + return only(run_call!( + context, + :reader; + environment=accepted_environment, + publish=true, + )) +end +``` + +`ToyEnvironmentControllerModel` applies those two operations in its kernel. +Configure the reader as its one hard-call target and give only the controller a +commit sink: + +```@example journey_mutable_environment +environment = ToySpatialEnvironment( + Dict(:canopy => (T=20.0,)); + step_seconds=3600.0, +) + +model = CompositeModel( + Object( + :leaf; + scale=:Leaf, + kind=:leaf, + geometry=(cell=:canopy,), + ); + applications=( + ModelSpec( + ToyEnvironmentReaderModel(); + name=:reader, + on=One(scale=:Leaf), + environment=Environment(backend=environment), + ), + ModelSpec( + ToyEnvironmentControllerModel(30.0, 22.0); + name=:controller, + on=One(scale=:Leaf), + calls=( + :reader => One( + scale=:Leaf, + application=:reader, + ), + ), + environment=Environment( + backend=environment, + sink=:cells, + ), + ), + ), +) + +simulation = run!(model; outputs=:all) +state = final_state(simulation) +( + trial_seen=state.trial_temperature_seen, + accepted_seen=state.accepted_temperature_seen, + committed=environment.cells[:canopy].T, +) +``` + +The trial was `30`, but the accepted and committed temperature is `22`. +Only the accepted reader call published: + +```@example journey_mutable_environment +select( + DataFrame(Diagnostics.explain_outputs(simulation)), + :application_id, + :variable, + :nsamples, +) +``` + +## Preserve distinct handles under `Many` + +Extend the same backend to two spatial cells. One `Many` application samples +both, while each target retains its own compiled handle: + +```@example journey_mutable_environment +spatial_environment = ToySpatialEnvironment( + Dict( + :sun => (T=26.0,), + :shade => (T=18.0,), + ); + step_seconds=3600.0, +) + +spatial_model = CompositeModel( + Object( + :sun_leaf; + scale=:Leaf, + geometry=(cell=:sun,), + ), + Object( + :shade_leaf; + scale=:Leaf, + geometry=(cell=:shade,), + ); + applications=( + ModelSpec( + ToyEnvironmentReaderModel(); + name=:temperature, + on=Many(scale=:Leaf), + environment=Environment(backend=spatial_environment), + ), + ), +) + +spatial_simulation = run!(spatial_model) +spatial_states = final_state(spatial_simulation, Many(scale=:Leaf)) +Dict(id => state.temperature_seen for (id, state) in spatial_states) +``` + +```@example journey_mutable_environment +select( + DataFrame(Diagnostics.explain_environment_bindings(spatial_model)), + :object_id, + :handle, +) +``` + +This ordinary user page does not require the backend implementation protocol. +Framework builders can follow [Environment Backend Extensions](@ref); the +complete MAESPA-style synthesis later combines mutable microclimate, several +plants, and iterative leaf calls. + +## Page recap + +- **You added:** one typed trial, one explicit accepted commit, one accepted + publication, and then two spatial cells. +- **PlantSimEngine inferred:** the reader call target, publication boundary, + commit permission check, and distinct `Many` handles. +- **You keep explicit:** trial and acceptance logic, `publish`, the committed + variables, controller sink, and backend state type. +- **New API names:** `environment_outputs_`, `run_call!`, + `commit_environment!`, `publish`, and `calls`. diff --git a/docs/src/journeys/users/one_object.md b/docs/src/journeys/users/one_object.md new file mode 100644 index 000000000..25091a6fe --- /dev/null +++ b/docs/src/journeys/users/one_object.md @@ -0,0 +1,96 @@ +# Couple Models On One Object + +## New concept: automatic same-object coupling over time + +This first executable simulation couples three existing models on one object: + +1. `ToyDegreeDaysCumulModel` reads temperature and accumulates thermal time. +2. `ToyLAIModel` reads cumulative thermal time and computes LAI. +3. `Beer` reads LAI and radiation and computes absorbed PAR. + +The weather file is supplied forcing data for now. Environments get their own +journey later. + +```@example journey_one_object +using PlantSimEngine, PlantMeteo, Dates, DataFrames +using PlantSimEngine.Examples + +weather = read_weather( + joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"); + duration=Day, +) + +model = CompositeModel( + ToyDegreeDaysCumulModel(), + ToyLAIModel(), + Beer(0.6); + environment=weather, +) +``` + +No `ModelSpec` or selector is needed when all models run on the one object made +by the concise constructor. Run thirty daily steps and retain the model +outputs: + +```@example journey_one_object +simulation = run!(model; steps=30, outputs=:all) +results = collect_outputs(simulation) + +thermal_time = results[results.variable .== :TT_cu, :value] +lai = results[results.variable .== :LAI, :value] +evolution = DataFrame( + step=1:length(thermal_time), + TT_cu=thermal_time, + LAI=lai, +) +vcat(first(evolution, 3), last(evolution, 3)) +``` + +The table is retained history. The latest values are also available directly, +whether or not history was requested: + +```@example journey_one_object +state_at_day_30 = final_state(simulation) +( + current_step=current_step(simulation), + TT_cu=state_at_day_30.TT_cu, + LAI=state_at_day_30.LAI, + aPPFD=state_at_day_30.aPPFD, + retained_streams=length(outputs(simulation)), +) +``` + +PlantSimEngine inferred both status connections because each has one +unambiguous producer on the same object. This focused diagnostic shows the +resolved sources and the live reference carriers: + +```@example journey_one_object +select( + DataFrame(Diagnostics.explain_bindings(model)), + :application_id, + :input, + :source_application_ids, + :carrier_kind, +) +``` + +A `Simulation` owns a continuing timeline. Advancing it does not rebuild a +separate result object: + +```@example journey_one_object +step!(simulation) +state_at_day_31 = final_state(simulation) +(current_step=current_step(simulation), TT_cu=state_at_day_31.TT_cu) +``` + +## Page recap + +- **You added:** three models, supplied weather, a 30-step run, and retained + outputs. +- **PlantSimEngine inferred:** the one object, three applications, their + execution order, and the `TT_cu` and `LAI` connections. +- **You keep explicit:** model parameters, forcing data, number of steps, and + whether output history is retained. +- **New API names:** `CompositeModel`, `run!`, `Simulation`, `final_state`, + `collect_outputs`, `outputs`, `current_step`, `step!`, and + `Diagnostics.explain_bindings`. diff --git a/docs/src/journeys/users/one_plant.md b/docs/src/journeys/users/one_plant.md new file mode 100644 index 000000000..cbb13eae8 --- /dev/null +++ b/docs/src/journeys/users/one_plant.md @@ -0,0 +1,200 @@ +# Build One Multiscale Plant + +## New concept: topology and cross-object values + +The previous journey used independent objects at one scale. A multiscale plant +adds parent/child topology: one plant object owns two leaf objects. + +The scope picture for this page is: + +> `:plant` — `Subtree()` from here contains `:plant`, `:leaf_1`, and `:leaf_2`
+> ├─ `:leaf_1` — `Self()` is `:leaf_1`; `SelfPlant()` resolves to `:plant`
+> └─ `:leaf_2` — `Self()` is `:leaf_2`; `SelfPlant()` resolves to `:plant` + +Selectors still filter that scope. For example, +`Many(scale=:Leaf, within=Subtree())` selects the two leaves when evaluated for +the plant application. + +## First pass: one scalar value from plant to leaves + +Start with leaf surfaces and total plant surface supplied as status. The only +new value connection sends the plant-level absorbed light to each leaf. + +```@example journey_one_plant +using PlantSimEngine, DataFrames +using PlantSimEngine.Examples + +objects = ( + Object( + :plant; + scale=:Plant, + kind=:plant, + status=Status(aPPFD=120.0, surface=3.0), + ), + Object( + :leaf_1; + scale=:Leaf, + kind=:leaf, + parent=:plant, + status=Status(surface=1.0), + ), + Object( + :leaf_2; + scale=:Leaf, + kind=:leaf, + parent=:plant, + status=Status(surface=2.0), + ), +) + +scalar_model = CompositeModel( + objects...; + applications=( + ModelSpec( + ToyLightPartitioningModel(); + name=:leaf_light, + on=Many(scale=:Leaf), + inputs=( + :aPPFD_larger_scale => One( + scale=:Plant, + within=SelfPlant(), + var=:aPPFD, + ), + :total_surface => One( + scale=:Plant, + within=SelfPlant(), + var=:surface, + ), + ), + ), + ), +) + +scalar_simulation = run!(scalar_model; outputs=:all) +scalar_states = final_state(scalar_simulation, Many(scale=:Leaf)) +Dict(id => state.aPPFD for (id, state) in scalar_states) +``` + +The leaf model reads its own `surface` directly from each leaf status. +`SelfPlant()` makes the other two scalar sources plant-local: + +```@example journey_one_plant +select( + DataFrame(Diagnostics.explain_bindings(scalar_model)), + :consumer_id, + :input, + :source_ids, + :carrier_kind, +) +``` + +## Second pass: compute and aggregate leaf surfaces + +Now replace the supplied surfaces with two existing models: + +- `ToyLeafSurfaceModel` computes each leaf surface from its carbon biomass; +- `ToyPlantLeafSurfaceModel` sums those leaf surfaces on the plant. + +This is the first vector-like cross-object input. It comes after the scalar +connection above, and differs only in the new `:leaf_surfaces` binding. + +```@example journey_one_plant +computed_objects = ( + Object( + :plant; + scale=:Plant, + kind=:plant, + status=Status(aPPFD=120.0), + ), + Object( + :leaf_1; + scale=:Leaf, + kind=:leaf, + parent=:plant, + status=Status(carbon_biomass=50.0), + ), + Object( + :leaf_2; + scale=:Leaf, + kind=:leaf, + parent=:plant, + status=Status(carbon_biomass=100.0), + ), +) + +computed_model = CompositeModel( + computed_objects...; + applications=( + ModelSpec( + ToyLeafSurfaceModel(0.02); + name=:leaf_surface, + on=Many(scale=:Leaf), + ), + ModelSpec( + ToyPlantLeafSurfaceModel(); + name=:plant_surface, + on=One(scale=:Plant), + inputs=( + :leaf_surfaces => Many( + scale=:Leaf, + within=Subtree(), + application=:leaf_surface, + var=:surface, + ), + ), + ), + ModelSpec( + ToyLightPartitioningModel(); + name=:leaf_light, + on=Many(scale=:Leaf), + inputs=( + :aPPFD_larger_scale => One( + scale=:Plant, + within=SelfPlant(), + var=:aPPFD, + ), + :total_surface => One( + scale=:Plant, + within=SelfPlant(), + application=:plant_surface, + var=:surface, + ), + ), + ), + ), +) + +computed_simulation = run!(computed_model; outputs=:all) +plant_state = final_state(computed_simulation, One(scale=:Plant)) +leaf_states = final_state(computed_simulation, Many(scale=:Leaf)) +( + plant_surface=plant_state.surface, + leaf_surfaces=Dict(id => state.surface for (id, state) in leaf_states), + leaf_light=Dict(id => state.aPPFD for (id, state) in leaf_states), +) +``` + +The plant aggregation uses a live `RefVector`; the scalar connections remain +single references: + +```@example journey_one_plant +select( + DataFrame(Diagnostics.explain_bindings(computed_model)), + :application_id, + :consumer_id, + :input, + :source_ids, + :carrier_kind, +) +``` + +## Page recap + +- **You added:** parent/child topology, plant-to-leaf scalar connections, and + one plant-local vector aggregation. +- **PlantSimEngine inferred:** same-leaf `surface` coupling, application order, + and live scalar/vector reference carriers. +- **You keep explicit:** object parentage, the scope of cross-object searches, + and the source application when selecting a produced value. +- **New API names:** `parent`, `Self`, `SelfPlant`, `Subtree`, and the + `application` and `var` selector fields. diff --git a/docs/src/journeys/users/several_objects.md b/docs/src/journeys/users/several_objects.md new file mode 100644 index 000000000..19fba3432 --- /dev/null +++ b/docs/src/journeys/users/several_objects.md @@ -0,0 +1,102 @@ +# Run The Coupling On Several Objects + +## New concept: stable object identity and `Many` + +The previous page ran one model chain on one automatically created object. The +smallest extension is to create two same-scale objects and target both with one +reusable `Many` selector. + +```@example journey_several_objects +using PlantSimEngine, PlantMeteo, Dates, DataFrames +using PlantSimEngine.Examples + +weather = read_weather( + joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"); + duration=Day, +) + +plants = ( + Object( + :plant_a; + scale=:Plant, + kind=:plant, + status=Status(TT_cu=0.0), + ), + Object( + :plant_b; + scale=:Plant, + kind=:plant, + status=Status(TT_cu=200.0), + ), +) +plant_targets = Many(scale=:Plant) + +model = CompositeModel( + plants...; + applications=( + ModelSpec( + ToyDegreeDaysCumulModel(); + name=:degree_days, + on=plant_targets, + ), + ModelSpec(ToyLAIModel(); name=:lai, on=plant_targets), + ModelSpec(Beer(0.6); name=:light, on=plant_targets), + ), + environment=weather, +) +``` + +`:plant_a` and `:plant_b` are stable object identities. Their initial +cumulative thermal times differ, but the same three model kernels execute for +both. The model implementations contain no loop over plants. + +The application diagnostic confirms that each application compiled to both +objects: + +```@example journey_several_objects +select( + DataFrame(Diagnostics.explain_applications(model)), + :application_id, + :target_ids, +) +``` + +Run five steps and inspect each independent final status: + +```@example journey_several_objects +simulation = run!(model; steps=5, outputs=:all) +states = final_state(simulation, Many(scale=:Plant)) +Dict( + id => (TT_cu=state.TT_cu, LAI=state.LAI, aPPFD=state.aPPFD) + for (id, state) in states +) +``` + +Retained streams are keyed by application, object, and variable, so the two +objects do not overwrite one another: + +```@example journey_several_objects +rows = collect_outputs(simulation) +lai_rows = rows[rows.variable .== :LAI, [ + :timestep, + :application_id, + :object_id, + :value, +]] +first(lai_rows, 6) +``` + +This remains a same-scale simulation. Parent/child topology and cross-object +value selection are introduced on the next journey, after independent object +execution is established. + +## Page recap + +- **You added:** two explicit `Object`s, stable ids, one shared `Many` selector, + and named `ModelSpec` applications. +- **PlantSimEngine inferred:** two targets per application plus independent + same-object `TT_cu` and `LAI` connections for each plant. +- **You keep explicit:** which objects exist, their initial status, application + names, and the selector describing the target set. +- **New API names:** `Object`, `Status`, `ModelSpec`, `Many`, and + `Diagnostics.explain_applications`. diff --git a/docs/src/journeys/users/several_plants.md b/docs/src/journeys/users/several_plants.md new file mode 100644 index 000000000..a468532c1 --- /dev/null +++ b/docs/src/journeys/users/several_plants.md @@ -0,0 +1,204 @@ +# Instantiate Several Plants + +## New concept: templates, instances, and overrides + +The previous journey configured one plant explicitly. Its three applications +can become a `CompositeModelTemplate`, then be mounted on several independent +object topologies without duplicating that model configuration. + +```@example journey_several_plants +using PlantSimEngine, DataFrames +using PlantSimEngine.Examples + +plant_template = CompositeModelTemplate(( + ModelSpec( + ToyLeafSurfaceModel(0.02); + name=:leaf_surface, + on=Many(scale=:Leaf), + ), + ModelSpec( + ToyPlantLeafSurfaceModel(); + name=:plant_surface, + on=One(scale=:Plant), + inputs=( + :leaf_surfaces => Many( + scale=:Leaf, + within=Subtree(), + application=:leaf_surface, + var=:surface, + ), + ), + ), + ModelSpec( + ToyLightPartitioningModel(); + name=:leaf_light, + on=Many(scale=:Leaf), + inputs=( + :aPPFD_larger_scale => One( + scale=:Plant, + within=SelfPlant(), + var=:aPPFD, + ), + :total_surface => One( + scale=:Plant, + within=SelfPlant(), + application=:plant_surface, + var=:surface, + ), + ), + ), +)) +``` + +An `ObjectInstance` supplies the concrete root and organs. These two instances +reuse the same template while keeping different initial plant radiation and +leaf biomasses: + +```@example journey_several_plants +plant_a = ObjectInstance( + :plant_a, + plant_template; + root=Object( + :plant_a_root; + scale=:Plant, + kind=:plant, + status=Status(aPPFD=120.0), + ), + objects=( + Object( + :plant_a_leaf_1; + scale=:Leaf, + kind=:leaf, + parent=:plant_a_root, + status=Status(carbon_biomass=50.0), + ), + Object( + :plant_a_leaf_2; + scale=:Leaf, + kind=:leaf, + parent=:plant_a_root, + status=Status(carbon_biomass=100.0), + ), + ), +) + +plant_b = ObjectInstance( + :plant_b, + plant_template; + root=Object( + :plant_b_root; + scale=:Plant, + kind=:plant, + status=Status(aPPFD=200.0), + ), + objects=( + Object( + :plant_b_leaf_1; + scale=:Leaf, + kind=:leaf, + parent=:plant_b_root, + status=Status(carbon_biomass=50.0), + ), + Object( + :plant_b_leaf_2; + scale=:Leaf, + kind=:leaf, + parent=:plant_b_root, + status=Status(carbon_biomass=50.0), + ), + ), +) + +model = CompositeModel(plant_a, plant_b) +simulation = run!(model; outputs=:all) +plant_states = final_state(simulation, Many(scale=:Plant)) +Dict(id => (surface=state.surface, aPPFD=state.aPPFD) for (id, state) in plant_states) +``` + +Plant A aggregates surfaces `1 + 2 = 3`; plant B aggregates `1 + 1 = 2`. +Those totals prove that `Subtree()` did not mix leaves between instances. +Likewise, each pair of leaf-level light outputs sums to its own plant's +supplied light: + +```@example journey_several_plants +leaf_states = final_state(simulation, Many(scale=:Leaf)) +( + plant_a_light=sum( + leaf_states[id].aPPFD + for id in (:plant_a_leaf_1, :plant_a_leaf_2) + ), + plant_b_light=sum( + leaf_states[id].aPPFD + for id in (:plant_b_leaf_1, :plant_b_leaf_2) + ), +) +``` + +The instance diagnostic shows the mounted object and application ids. Template +application names are prefixed automatically, so the two mounted graphs remain +unambiguous: + +```@example journey_several_plants +select( + DataFrame(Diagnostics.explain_instances(model)), + :name, + :root_id, + :object_ids, + :application_ids, +) +``` + +## Override one instance + +Only after the two unchanged instances work, override one application for a +new instance. This plant uses a larger specific leaf area while retaining the +same logical `:leaf_surface` application and all other template wiring: + +```@example journey_several_plants +plant_c = ObjectInstance( + :plant_c, + plant_template; + root=Object( + :plant_c_root; + scale=:Plant, + kind=:plant, + status=Status(aPPFD=120.0), + ), + objects=( + Object( + :plant_c_leaf_1; + scale=:Leaf, + kind=:leaf, + parent=:plant_c_root, + status=Status(carbon_biomass=50.0), + ), + Object( + :plant_c_leaf_2; + scale=:Leaf, + kind=:leaf, + parent=:plant_c_root, + status=Status(carbon_biomass=100.0), + ), + ), + overrides=(leaf_surface=ToyLeafSurfaceModel(0.04),), +) + +override_simulation = run!(CompositeModel(plant_c)) +override_state = final_state(override_simulation, One(scale=:Plant)) +override_state.surface +``` + +There is no `SceneScope()` in this example because nothing is deliberately +shared between plants. Introduce scene-wide scope only when adding a real +shared source, such as a soil object or scene-level forcing controller. + +## Page recap + +- **You added:** one reusable `CompositeModelTemplate`, two independent + `ObjectInstance`s, and then one application override. +- **PlantSimEngine inferred:** instance-local selector scopes, prefixed mounted + application ids, and the same compiled coupling graph for each plant. +- **You keep explicit:** each instance's objects and initial values, plus the + exact application replaced by an override. +- **New API names:** `CompositeModelTemplate`, `ObjectInstance`, `overrides`, + and `Diagnostics.explain_instances`. diff --git a/docs/src/journeys/users/structure_changes.md b/docs/src/journeys/users/structure_changes.md new file mode 100644 index 000000000..f745b2b8c --- /dev/null +++ b/docs/src/journeys/users/structure_changes.md @@ -0,0 +1,209 @@ +# Modify Plant Structure + +## New concept: lifecycle changes refresh compiled targets + +Start with one plant, one branch, and two leaves. Each leaf computes carbon +demand, then treats that fully met demand as accepted carbon allocation for +`ToyCBiomassModel`. This small chain gives us a conserved quantity to check +while topology changes. + +```@example journey_structure +using PlantSimEngine, DataFrames +using PlantSimEngine.Examples + +model = CompositeModel( + Object(:plant; scale=:Plant, kind=:plant), + Object(:branch; scale=:Axis, kind=:axis, parent=:plant), + Object( + :leaf_1; + scale=:Leaf, + kind=:leaf, + parent=:plant, + status=Status(TT=10.0), + ), + Object( + :leaf_2; + scale=:Leaf, + kind=:leaf, + parent=:plant, + status=Status(TT=15.0), + ); + applications=( + ModelSpec( + ToyCDemandModel( + optimal_biomass=12.0, + development_duration=120.0, + ); + name=:carbon_demand, + on=Many(scale=:Leaf), + ), + ModelSpec( + ToyCBiomassModel(1.2); + name=:biomass, + on=Many(scale=:Leaf), + inputs=( + :carbon_allocation => One( + within=Self(), + application=:carbon_demand, + var=:carbon_demand, + ), + ), + ), + ), +) + +simulation = run!(model; outputs=:all) +initial_targets = only( + row for row in Diagnostics.explain_applications(simulation) + if row.application_id == :biomass +).target_ids +``` + +## Add one leaf + +Registering an object mutates the live model and marks affected compiled state +dirty. Because this call happens between simulation steps, the new leaf is +compiled before the next step: + +```@example journey_structure +register_object!( + model, + Object( + :leaf_3; + scale=:Leaf, + kind=:leaf, + status=Status(TT=20.0), + ); + parent=:plant, +) + +targets_before_refresh = only( + row for row in Diagnostics.explain_applications(simulation) + if row.application_id == :biomass +).target_ids + +continue!(simulation) + +targets_after_refresh = only( + row for row in Diagnostics.explain_applications(simulation) + if row.application_id == :biomass +).target_ids + +( + initial_targets=initial_targets, + before_refresh=targets_before_refresh, + after_refresh=targets_after_refresh, + leaf_3_parent=only( + object.parent.value + for object in model_objects(model) + if object.id == ObjectId(:leaf_3) + ), +) +``` + +When a lifecycle operation occurs *inside* a model kernel, PlantSimEngine +refreshes after that application. A newly registered object may therefore run +applications that remain later in the same timestep, but it never +retroactively runs applications that already completed. + +## Reparent, then remove + +Creation now works, so make two further changes in order. First move the new +leaf under the branch and advance: + +```@example journey_structure +reparent_object!(model, :leaf_3, :branch) +continue!(simulation) + +leaf_3_parent = only( + object.parent.value + for object in model_objects(model) + if object.id == ObjectId(:leaf_3) +) +``` + +Then remove `:leaf_2` and advance once more: + +```@example journey_structure +removed = remove_object!(model, :leaf_2) +continue!(simulation) + +( + removed=removed.id.value, + current_leaves=sort!([ + object.id.value + for object in model_objects(model; scale=:Leaf) + ]), + leaf_3_parent=leaf_3_parent, + current_targets=only( + row for row in Diagnostics.explain_applications(simulation) + if row.application_id == :biomass + ).target_ids, +) +``` + +## Check conservation and history + +For every retained leaf sample, accepted carbon allocation equals demand. The +biomass model partitions it into biomass increment plus growth respiration: + +```@example journey_structure +rows = collect_outputs(simulation; sink=nothing) + +demand = Dict( + (row.timestep, row.object_id) => row.value + for row in rows + if row.application_id == :carbon_demand && + row.variable == :carbon_demand +) +increment = Dict( + (row.timestep, row.object_id) => row.value + for row in rows + if row.application_id == :biomass && + row.variable == :carbon_biomass_increment +) +respiration = Dict( + (row.timestep, row.object_id) => row.value + for row in rows + if row.application_id == :biomass && + row.variable == :growth_respiration +) + +all( + demand[key] ≈ increment[key] + respiration[key] + for key in keys(demand) +) +``` + +Removed-object history remains queryable even though the object is no longer +in the registry: + +```@example journey_structure +history_counts = Dict( + id => length(collect_outputs( + simulation, + id, + :carbon_biomass; + sink=nothing, + )) + for id in (:leaf_1, :leaf_2, :leaf_3) +) +``` + +`:leaf_2` keeps the three samples published before removal; `:leaf_3` begins at +step 2, after registration, and also has three samples. + +`ToyCAllocationModel` is useful when supply is limiting and a plant controller +must divide carbon among many organ demands. This journey deliberately assumes +all demand is accepted so lifecycle timing and conservation stay visible +without introducing a controller or hard calls. + +## Page recap + +- **You added:** one leaf, then one reparenting operation, then one removal. +- **PlantSimEngine inferred:** the affected application targets, status views, + reference binding, execution batch extension, and retained stream keys. +- **You keep explicit:** initialized status for a new object, its parent, + conservation assumptions, and when removal or reparenting occurs. +- **New API names:** `register_object!`, `reparent_object!`, `remove_object!`, + and `Diagnostics.explain_applications(simulation)`. diff --git a/docs/src/migration_composite_model.md b/docs/src/migration_composite_model.md new file mode 100644 index 000000000..8dec94b75 --- /dev/null +++ b/docs/src/migration_composite_model.md @@ -0,0 +1,497 @@ +# 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` has been removed. Use `outputs=:all`, `outputs=:none`, or +`outputs=requests` directly. Singular scenario `inputs` and `calls`, +`OutputRequest`, object overrides, and `Updates(...; after=...)` now identify +the target by canonical `application=...` or application ID. Model-authored +`Input`/`Call` defaults may still discover a process because they cannot know +scenario application names, and `Many(process=...)` remains an explicit +multi-application discovery query. + +Calling `run!(model; ...)` always creates a fresh result timeline starting at +step one, even when object status has already been mutated by an earlier run. +Continue the same timeline, environment position, temporal histories, and +multirate phase with: + +```julia +simulation = run!(model; steps=24, outputs=requests) +continue!(simulation; steps=24) +step!(simulation) +@assert current_step(simulation) == 49 +``` + +The 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 +Updates +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) +environment_inputs_(model) +run!(model, status, environment, constants, context) +``` + +This page maps the legacy configuration concepts to their composite-model/object +equivalents. + +## Explicit Input Initialization + +Input literals no longer double as ambiguous placeholder values. Declare +whether each model input is required or genuinely has a fallback: + +```julia +# Old, ambiguous +inputs_(::GrowthModel) = ( + temperature=-Inf, + efficiency=0.8, +) + +# Current +inputs_(::GrowthModel) = ( + temperature=Required(Float64), + efficiency=Default(0.8), +) +``` + +`Required(T)` means object state or another application must supply the value. +`Default(value)` means PlantSimEngine may initialize it when absent. Output +literals remain initial output-state values. Plain input literals are rejected; +there is no compatibility interpretation of `-Inf` or another sentinel. + +Replace helpers that previously merged every input literal into initial state +with `init_variables(model)`. It returns only real input defaults and output +initial values, omitting required inputs. + +## Scenario Structure + +Legacy simulations split configuration between `ModelMapping` and +`MultiScaleModel`. The unified API stores runtime entities in one `CompositeModel`: + +`ModelMapping` has been removed. Historical code must be translated to the +composite-model/object form below. + +```julia +model = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:plant_1; scale=:Plant, kind=:plant, parent=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:plant_1), + Object(:soil; scale=:Soil, kind=:soil, parent=:scene); + applications=( + ModelSpec(LeafModel(); name=:leaf_model, on=Many(scale=:Leaf)), + + ModelSpec(SoilModel(); name=:soil_model, on=One(scale=:Soil)), + ), + environment=(T=25.0, Rh=0.6, Wind=1.0), +) +``` + +`Object` labels describe 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=environment, + kind=node_kind, + species=node_species, + geometry=node_geometry, +) +``` + +`objects_from_mtg(mtg; ...)` exposes the intermediate object list when it is +useful to inspect or modify labels before constructing the model. By default, +the adapter uses MTG node ids and scales, and reuses an existing +`:plantsimengine_status` attribute when present. + +## Multiscale Inputs + +Replace `MultiScaleModel(...)` variable mappings with consumer-side +`ModelSpec(...; inputs=...)`. + +Legacy: + +```julia +MultiScaleModel( + AllocationModel(), + [:leaf_carbon => [:Leaf => :leaf_carbon]], +) +``` + +Unified: + +```julia +ModelSpec( + AllocationModel(); + name=:allocation, + on=Many(scale=:Plant), + inputs=( + :leaf_carbon => Many( + scale=:Leaf, + within=Subtree(), + var=:leaf_carbon, + ), + ), +) +``` + +`Self()` selects only the object where the consumer runs. A plant-scale +allocation model uses `Subtree()` to read leaves below that plant. Use +`SceneScope()` for model-wide aggregation and `SelfPlant()` to select the +nearest containing plant and its subtree from an organ. + +Same-object renaming uses the same syntax: + +```julia +ModelSpec( + ConsumerModel(); + inputs=( + :consumer_name => One( + within=Self(), + application=:producer, + var=:producer_name, + ), + ), +) +``` + +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, on=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 `ModelSpec(...; calls=...)` when a parent model must control child execution. + +```julia +ModelSpec(SceneEnergyBalance(); name=:scene_energy, on=One(scale=:Scene), calls=(:leaf_energy => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:energy_balance, + ), + :soil => One( + kind=:soil, + scale=:Soil, + within=SceneScope(), + application=:soil_water, + ),)) +``` + +The parent model controls execution: + +```julia +function PlantSimEngine.run!(model::SceneEnergyBalance, status, environment, + constants, context) + for iteration in 1:model.max_iterations + trial = trial_environment(model, status) + run_call!(context, :leaf_energy; environment=trial, publish=false) + converged(model, status) && break + end + + accepted = accepted_environment(model, status) + commit_environment!(context, accepted) + run_call!(context, :leaf_energy; publish=true) + return nothing +end +``` + +`run_call!` defaults to `publish=false`. Trial calls mutate target status but +do not publish temporal samples or commit mutable environment updates. +do not append temporal samples or write environment outputs. The accepted +state must use `publish=true`. + +## Multiple Plants And Species + +Represent repeated plant configurations with `CompositeModelTemplate` and +`ObjectInstance`. + +```julia +oil_palm = CompositeModelTemplate( + ( + ModelSpec(LeafEnergy(); on=Many(scale=:Leaf)), + + ModelSpec(Allocation(); on=One(scale=:Plant), inputs=(:leaf_carbon => Many( + scale=:Leaf, + within=Subtree(), + var=:leaf_carbon, + ),)), + ); + kind=:plant, + species=:oil_palm, +) + +palm_1 = ObjectInstance( + :palm_1, + oil_palm; + root=Object(:plant_1; scale=:Plant, parent=:scene), + objects=(Object(:palm_1_leaf_1; scale=:Leaf, parent=:plant_1),), +) + +palm_2 = ObjectInstance( + :palm_2, + oil_palm; + root=Object(:plant_2; scale=:Plant, parent=:scene), + objects=(Object(:palm_2_leaf_1; scale=:Leaf, parent=:plant_2),), +) + +model = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + palm_1, + palm_2, +) +``` + +Unmodified instances share model objects and parameters. Use instance +overrides for one plant and `Override(...)` for exceptional organs. + +## Multirate Inputs + +Replace `TimeStepModel(...)` with `ModelSpec(...; every=...)`. Put temporal policy and +window information on the consuming `ModelSpec(...; inputs=...)` selector. + +```julia +ModelSpec(HourlyLeafModel(); name=:leaf_flux, on=Many(scale=:Leaf), every=Hour(1)) + +ModelSpec(DailyPlantModel(); name=:daily_plant, on=Many(scale=:Plant), inputs=(:leaf_fluxes => Many( + scale=:Leaf, + within=Subtree(), + application=:leaf_flux, + var=:flux, + policy=Integrate(), + window=Day(1), + ),), every=Day(1)) +``` + +Use `HoldLast()`, `Interpolate()`, `Integrate()`, or `Aggregate()` according to +the physical meaning of the input. `PreviousTimeStep(:x) => selector` expresses +an explicit lag and breaks a same-timestep dependency cycle. + +If the input selector omits `policy=...`, the model compiler uses the +producer's `output_policy(...)` trait for the selected source variable when the +publisher is unique. An explicit selector policy always wins over the trait. + +If a model defines `timespec(::Type{<:MyModel})`, the model scheduler uses that +cadence when the application has no explicit `ModelSpec(...; every=...)`. A scenario-level +`ModelSpec(...; every=...)` always wins over the model trait. + +If the clock falls back to the model base step, `timestep_hint(...)` required +bounds are validated against that base step. The hint is a compatibility +constraint, not a scheduling override. + +## Ordered Variable Updates + +When several models intentionally write the same variable, declare the order +on the later application: + +```julia +ModelSpec(CarbonAllocation(); name=:allocation, on=Many(scale=:Leaf)) + +ModelSpec(LeafPruning(); name=:pruning, on=Many(scale=:Leaf), updates=Updates(:leaf_biomass; after=:allocation)) +``` + +Do not encode this coupling in either model implementation. The scenario owns +the writer order. + +## Environment And Microclimate + +Models declare sampled environment variables with `environment_inputs_`. The scenario +binds each object/application to the active environment backend: + +```julia +ModelSpec(LeafEnergy(); name=:leaf_energy, on=Many(scale=:Leaf), environment=Environment(provider=:grid)) +``` + +Spatial bindings are cached. The default resolver uses the object's geometry, +then the nearest ancestor geometry, then the backend's global behavior. +Changing geometry invalidates only affected environment bindings. + +Per-model source remapping also moves to `Environment(...)`: + +```julia +ModelSpec(LeafGasExchange(); name=:gas_exchange, on=Many(scale=:Leaf), environment=Environment(provider=:global, sources=(CO2=:Ca,))) +``` + +The model still declares and reads `CO2`; the model samples `Ca` from the +active environment backend and exposes it to the model as `environment.CO2`. +`Diagnostics.explain_environment_bindings(...)` reports both `required_inputs` and +`source_inputs`, so remapped meteorology is visible to users and agents. + +Model authors can also provide default source remaps with +`environment_hint(::Type{<:Model}) = (bindings=(CO2=(source=:Ca,),),)`. CompositeModel +applications use those defaults when the scenario does not provide an explicit +source for the same variable. `Environment(; sources=...)` remains the +scenario-level override. + +For global `Weather` tables, sampling follows the application's +`ModelSpec(...; every=...)`. A slower model receives a PlantMeteo windowed sample using its +`environment_hint` reducer and window instead of receiving only the current raw +weather row. A scenario source override preserves that reducer: + +```julia +environment_hint(::Type{<:GasExchange}) = ( + bindings=(CO2=(source=:Ca, reducer=MeanReducer()),), +) + +ModelSpec(GasExchange(); on=Many(scale=:Leaf), every=Hour(2), environment=Environment(provider=:global, sources=(CO2=:canopy_CO2,))) +``` + +Every leaf still reads `environment.CO2`; the two-hour mean is computed from +`:canopy_CO2`. The sampled row is computed once per application and timestep, +then reused for all selected leaves. + +## Growth, Pruning, And Movement + +Use the public lifecycle operations: + +```julia +register_object!(model, new_leaf; parent=:plant_1) +new_leaf_status = add_organ!( + parent_node, + model, + :+, + :Leaf, + 3; + initial_status=(biomass=0.0,), +) +remove_object!(model, :old_leaf) +reparent_object!(model, :leaf_2, :axis_3) +move_object!(model, :leaf_3, new_geometry) +``` + +For MTG-backed growth, prefer `add_organ!`: it creates the MTG node and its +model object together and 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 after the application that made the change. +New objects can therefore run applications that remain later in the current +timestep, but never applications that already ran. Geometry-only changes +refresh environment bindings without rebuilding unrelated structural bindings. + +The refreshed runtime also rebuilds homogeneous execution batches. Use +`Diagnostics.explain_execution_plan(scene_or_simulation)` to inspect the concrete +model/status/carrier types and the objects grouped into each specialized inner +loop. Exceptional per-object model overrides appear as separate ordered +batches. + +## Output Collection + +`run!(model; steps=...)` returns a `Simulation`. Use `final_state(sim)` for the +latest one-object state, `outputs(sim)` for retained typed streams, +`Diagnostics.explain_outputs(sim)` for structured diagnostics, and +`collect_outputs(sim)` for tabular rows. + +```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 +`ModelSpec(...; inputs=...)`. Use `Diagnostics.explain_output_retention(sim)` +to inspect why each retained stream was kept. Dependency-only streams retain a +bounded policy-specific horizon, while requested streams keep complete +histories for post-run export. Export is not yet a fully online path. + +## Inspecting The Compiled Scenario + +Use structured explanations instead of inspecting internal dictionaries: + +```julia +Diagnostics.explain_objects(model) +Diagnostics.explain_instances(model) +Diagnostics.explain_scopes(model) +Diagnostics.explain_applications(model) +Diagnostics.explain_bindings(model) +Diagnostics.explain_calls(model) +Diagnostics.explain_environment_bindings(model) +Diagnostics.explain_schedule(model) +Diagnostics.explain_writers(model) +``` + +These 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 `ModelSpec(...; inputs=...)` | +| `TimeStepModel(...)` | `ModelSpec(...; every=...)` | +| `InputBindings(...)` | source, policy, and window on `ModelSpec(...; inputs=...)` | +| `MeteoBindings(...)` | automatic environment binding or `Environment(...)` | +| `ScopeModel(...)` | `ModelSpec(...; on=...)` and selector scopes | +| `SameScale()` 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..0a0352dd7 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 has one application constructor: -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 +ModelSpec( + model; + name=:application, + on=Many(scale=:Leaf), + inputs=(...), + calls=(...), + every=Dates.Hour(1), + environment=Environment(...), + output_routing=(...), + updates=Updates(...), +) +``` -## 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. +Scenarios start from `CompositeModel` and model applications. -For a compact overview of all model traits and precedence rules, see [Model traits](model_traits.md). +## Model Kernels And Applications -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})` +A model kernel is still an ordinary PlantSimEngine model: -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. +- `inputs_(model)` declares each status input as `Required(T)` or + `Default(value)`; +- `outputs_(model)` declares variables the model computes and their initial + output-state values; +- `environment_inputs_(model)` declares environment variables it reads; +- `commit_environment!(context, state)` commits accepted mutable environment + state when the model intentionally controls microclimate; +- `dep(model)` may declare model-author defaults; +- `run!(model, status, environment, constants, context)` contains the model +equations. -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(...)`. +`Required(T)` has no initialization value: object state or a producer +application must satisfy it. `Default(value)` is installed only when the target +does not already have the input. Plain input literals are rejected because +they are ambiguous. -For inferred bindings, default policy is resolved as: -- producer `output_policy` for the source output when defined; -- otherwise `HoldLast()`. +The composite-model/object layer does not change that kernel contract. It adds a +scenario-specific application around the kernel: -`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. +```julia +ModelSpec( + LeafEnergyBalance(); + name=:leaf_energy, + on=Many(kind=:plant, scale=:Leaf), + inputs=(...), + calls=(...), + every=Dates.Hour(1), + environment=Environment(provider=:canopy), +) +``` -Explicit mapping policies still have priority (`InputBindings(..., policy=...)`) and can -complement trait defaults by defining additional bindings with different policies. +`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. -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. +## Compilation Before Runtime -For meteo hints: -- return `(; bindings=..., window=...)` where `bindings` matches `MeteoBindings(...)` - and `window` matches `MeteoWindow(...)`. -- Explicit `MeteoBindings(...)` / `MeteoWindow(...)` always take precedence. +Before the timestep loop, PlantSimEngine compiles the model into concrete +runtime carriers: -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. +1. `ModelSpec(...; on=...)` selectors are resolved to stable object ids. +2. `ModelSpec(...; inputs=...)` selectors are resolved to source object/application ids. +3. Same-rate inputs are wired as shared `Ref`s, `RefVector`s, or + heterogeneous object-reference vectors. +4. Temporal inputs are compiled as stream lookups with a policy such as + `HoldLast`, `Interpolate`, `Integrate`, or `Aggregate`. +5. `ModelSpec(...; calls=...)` declarations are compiled to callable target lists. +6. `Environment(...)` is bound to backend cells, layers, voxels, or global + weather providers. +7. The root application order is topologically sorted from value inputs and + `Updates(...)` ordering. +8. Root execution batches are grouped by concrete model/status/environment + types where possible. -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. +Selectors are not resolved in the hot loop. Runtime execution uses the +compiled indexes and carriers. -`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. +Useful inspection helpers: -### Timestep decision flow +```julia +Diagnostics.explain_applications(model) +Diagnostics.explain_bindings(model) +Diagnostics.explain_calls(model) +Diagnostics.explain_environment_bindings(model) +Diagnostics.explain_schedule(model) +Diagnostics.explain_execution_plan(model) +Diagnostics.explain_writers(model) +``` -When meteo is provided, `duration` is mandatory for each row (or the simulation errors). +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. -Runtime picks each model effective clock with this order: +## Soft Dependencies With Inputs -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. +Soft dependencies are value dependencies. A consumer model reads a variable +produced by another model through `ModelSpec(...; inputs=...)`. -Then runtime applies constraints: +```julia +ModelSpec(SceneLAI(ground_area); name=:scene_lai, on=One(scale=:Scene), inputs=(:leaf_areas => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:leaf_state, + var=:leaf_area, + ),)) +``` + +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. + +If an input is not explicitly declared with `ModelSpec(...; inputs=...)`, the compiler can +infer simple same-object bindings when exactly one producer on the same object +outputs the same variable. Ambiguous producers are errors and should be +disambiguated with `application=...` and, when names differ, `var=...`. -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. +Use `PreviousTimeStep(:x) => selector` when a feedback dependency should read +the previous sample instead of creating a same-timestep scheduling edge. -Practical consequences: +## Hard Calls With Calls -- 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: +Hard dependencies are manual calls. Use `ModelSpec(...; calls=...)` when a parent model must +control the call stack, for example during an iterative energy-balance solve. ```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) +ModelSpec(SceneEnergyBalance(); name=:scene_energy, on=One(scale=:Scene), calls=(:leaf_energy => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + application=:energy_balance, + ), + :soil => One( + kind=:soil, + scale=:Soil, + within=SceneScope(), + application=:soil_water, + ),), every=Dates.Hour(1)) ``` -### Calendar-aligned meteo windows +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. -`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` +```julia +soil_targets = run_call!(context, :soil; publish=true) +soil_status = only(soil_targets).status +``` -`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). +For finer-grained iterative control, retrieve targets without executing them +and decide when to publish the accepted state: -### Hold-last coupling (default policy) +```julia +function PlantSimEngine.run!(model::SceneEnergyBalance, status, environment, + constants, context) + trial = trial_environment(model, status) + run_call!(context, :leaf_energy; environment=trial, publish=false) + + accepted = accepted_environment(model, status) + commit_environment!(context, accepted) + run_call!(context, :leaf_energy; publish=true) + + return nothing +end +``` + +`run_call!` defaults to `publish=false`. Trial calls mutate target statuses but +do not publish temporal samples or commit mutable environment updates. Use +`environment=trial_state` when hard-called descendants should sample temporary +state through their compiled environment handles. Call `commit_environment!` and +`run_call!(...; publish=true)` once for the accepted state. + +Applications selected only by `ModelSpec(...; calls=...)` are marked manual-call-only in +`Diagnostics.explain_schedule(model)` and are skipped by the root `run!(model)` loop. + +## Duplicate Writers With Updates + +By default, one application owns each `(object, output variable)` canonical +writer. If a scenario intentionally lets several models update the same +variable, later writers must declare that order explicitly: ```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, on=Many(scale=:Leaf)) + +ModelSpec(LeafPruning(); name=:leaf_pruning, on=Many(scale=:Leaf), updates=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. `Diagnostics.explain_writers(model)` reports writer +groups and the `Updates(...)` declarations that validate them. +The `after` value is the canonical application identifier shown by +`Diagnostics.explain_applications(model)`, not the process name. + +## Multirate Execution + +Use `ModelSpec(...; every=...)` 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, on=Many(scale=:Leaf), every=Dates.Hour(1)) + +ModelSpec(DailyPlantAllocation(); name=:allocation, on=Many(scale=:Plant), inputs=(:leaf_assimilation => Many( + scale=:Leaf, + within=Subtree(), + application=:leaf_assim, + var=:A, + policy=Integrate(), + window=Dates.Day(1), + ),), every=Dates.Day(1)) +``` + +Clock precedence is: + +1. explicit `ModelSpec(...; every=...)` 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 +ModelSpec(CO2Probe(); name=:co2_probe, on=Many(scale=:Leaf), environment=Environment(provider=:canopy, sources=(CO2=:Ca,))) ``` -### Interpolate slow producer to fast consumer +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: + +- `environment_inputs_(model)` says what the model reads; +- `environment_outputs_(model)` says what the model may commit; +- `commit_environment!(context, accepted_environment)` commits accepted mutable + meteorology from a controller model; +- `run_call!(context, name; environment=trial_state)` exposes non-committing + trial state while preserving every target's compiled backend handle; +- `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. + +Backend authors implement an opaque-handle protocol: ```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())), - ), -) +handle = EnvironmentAPI.bind_environment(backend, object, context, config) + +EnvironmentAPI.sample(backend, handle, variable, time) # committed state +EnvironmentAPI.sample(backend, handle, trial_state, variable, time) # transient state +commit_environment!(backend, handle, accepted_state, time) ``` -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`. +`EnvironmentAPI.EnvironmentContext` identifies the application, object, scale, and process +while the handle is compiled. Runtime status and geometry are not passed to +sampling: a spatial backend resolves them once in `EnvironmentAPI.bind_environment` and stores +the resulting provider, layer, voxel, or other routing data in its concrete +handle. A controller that reads from one provider and commits to another should +encode both routes in the handle, for example +`Environment(provider=:forcing, sink=:canopy)`. + +Model-level `environment_hint(...)` can provide default source bindings and +aggregation rules. Scenario-level `Environment(...)` keeps precedence for +source names, while explicit sampling policy on `ModelSpec(...; inputs=...)` controls +model-to-model temporal values. -### Current limitations +## Running And Outputs -- 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. +Run a model with: -## Multi-rate output export (experimental) +```julia +sim = run!(model; steps=30) +``` -You can export selected variables at a requested rate from temporal streams: +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 -req = OutputRequest(:Leaf, :carbon_assimilation; - name=:A_daily, - process=:toyassim, +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) +Diagnostics.explain_output_retention(sim) +``` + +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 +continue!(sim; steps=24) +step!(sim) +current_step(sim) ``` -`tracked_outputs` accepts `OutputRequest` values for these resampled exports. -You can also return them directly from `run!`: +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. `Diagnostics.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 -out_status, exported = run!( - sim, - meteo; - tracked_outputs=[req], - return_requested_outputs=true, +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(context)` 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. diff --git a/docs/src/model_traits.md b/docs/src/model_traits.md index 6c9633451..58288c237 100644 --- a/docs/src/model_traits.md +++ b/docs/src/model_traits.md @@ -1,154 +1,123 @@ -# 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 `on`, `inputs`, `calls`, `every`, +`Environment`, `output_routing`, 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)` with an explicit declaration for every status input: ```julia -PlantSimEngine.timespec(::Type{<:AbstractModel}) = ClockSpec(1.0, 0.0) +PlantSimEngine.inputs_(::MyModel) = ( + leaf_area=Required(Float64), + efficiency=Default(0.8), +) +PlantSimEngine.outputs_(::MyModel) = (assimilation=0.0,) ``` -Use it when your model has a natural native clock (for example daily by default). +`Required(T)` means the value must be present on the target object's `Status` +or bound from another application. `T` is an expected type, not a placeholder +value. It may be abstract or parametric, so use the scientific type contract +instead of forcing `Float64`. -### `output_policy(::Type{<:MyModel})` +`Default(value)` means the model can run without user initialization or a +producer for that input. PlantSimEngine installs a private copy of `value` on +each target object when the value is absent. Mutable defaults are therefore +not shared between objects. -Defines per-output default schedule policy for produced streams. +Output literals remain initial output-state values. In the example, +`assimilation` starts at `0.0` before the first accepted model call. -Default: +These declarations are used for status initialization, dependency inference, +validation, and type construction. Plain input literals are rejected because +they do not say whether the value is required or genuinely optional. -```julia -PlantSimEngine.output_policy(::Type{<:AbstractModel}) = NamedTuple() -``` - -Behavior: +Use `init_variables(model)` to inspect only values PlantSimEngine can +initialize by itself: `Default` input values and output initial values. +Required inputs are intentionally omitted. -- 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. +Before running a scenario, `Diagnostics.explain_initialization(model)` classifies inputs as +`:required`, `:defaulted`, `:supplied`, or `:producer_bound`. A +`:required` row must be resolved before compilation can succeed. -Example: - -```julia -PlantSimEngine.output_policy(::Type{<:MyModel}) = ( - carbon_assimilation=Integrate(), - leaf_temperature=Aggregate(MeanReducer()), -) -``` +## Manual Dependencies -Users can always override or complement this trait at mapping level: +Implement `dep(model)` only when the model directly calls another process from +inside its own `run!` method: ```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.dep(::EnergyBalance) = ( + photosynthesis=AbstractPhotosynthesisModel, ) ``` -### `timestep_hint(::Type{<:MyModel})` +The scenario binds the dependency with `ModelSpec(...; calls=...)`. The parent executes all +resolved targets with `run_call!(context, :photosynthesis)`, which always returns +a vector-like collection. Use `call_targets` plus `run_call!(target)` when the +parent needs selective trials and accepted publication. -Optional compatibility hint when `TimeStepModel(...)` is not provided. +## Timing -Default: +`timespec(model)` declares the model's default clock. The default is +`ClockSpec(1.0, 0.0)`. ```julia -PlantSimEngine.timestep_hint(::Type{<:AbstractModel}) = nothing +PlantSimEngine.timespec(::Type{<:DailyGrowth}) = ClockSpec(Dates.Day(1)) ``` -Supported forms include: +`output_policy(model)` declares the default temporal policy per output: -- fixed period: `Dates.Hour(1)`; -- range: `(Dates.Minute(30), Dates.Hour(2))`; -- named tuple: `(; required=..., preferred=...)`. +```julia +PlantSimEngine.output_policy(::Type{<:MyModel}) = ( + assimilation=Integrate(), + leaf_temperature=Aggregate(MeanReducer()), +) +``` -`required` is enforced when runtime uses meteo-derived timestep. -`preferred` is informational only. +Unspecified outputs use `HoldLast()`. A scenario can select another clock with +`ModelSpec(...; every=...)` and another input policy in `ModelSpec(...; inputs=...)`. -### `meteo_hint(::Type{<:MyModel})` +`timestep_hint(model)` can declare required or preferred timestep constraints. +`environment_hint(model)` can provide default environment sampling configuration. -Optional inference trait for weather sampling configuration. +## Environment Variables -Default: +Use `environment_inputs_(model)` for variables sampled from the active environment +backend: ```julia -PlantSimEngine.meteo_hint(::Type{<:AbstractModel}) = nothing +PlantSimEngine.environment_inputs_(::LeafEnergyBalance) = ( + T=0.0, + Rh=0.0, + Wind=0.0, + Ri_PAR_f=0.0, + CO2=400.0, +) ``` -Expected value: +Mutable microclimate updates should be committed explicitly by controller +models: ```julia -(; bindings=..., window=...) +commit_environment!(context, accepted_environment) ``` -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. +For trial solves, pass a backend-specific state with `environment` so each +hard-called model keeps its compiled provider or spatial handle: -## 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`). +```julia +run_call!(context, :leaf_energy; environment=trial_environment, publish=false) +``` -This distinction reflects role: +Diagnostic variables such as canopy temperature or vapor-pressure deficit can +still be regular `outputs_`, but status fields are not the transport mechanism +for mutable environment state. -- 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. `ModelSpec(...; inputs=...)` policy, then producer `output_policy`, then `HoldLast()`. +2. `ModelSpec(...; every=...)`, then `timespec(model)`, then the environment base step. +3. `Environment(...)`, then `environment_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..af2e89cc4 100644 --- a/docs/src/planned_features.md +++ b/docs/src/planned_features.md @@ -1,51 +1,53 @@ # Roadmap -This page summarizes work that is still in progress or intentionally left for -future releases. It is not a guarantee of delivery order. +PlantSimEngine now has one composite-model/object runtime for single-object, multiscale, +multi-plant, soil, microclimate, and multirate simulations. -## Current focus areas +Current priorities are: -### Multi-rate MTG simulations +- 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. -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`. +## Environment and microclimate work -Known gaps in the current implementation: +### Trial environment sampling -- 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. +Coupled microclimate solvers can iterate on local environmental state before +accepting a timestep. A canopy energy-balance model, for example, may need to: -### Multi-plant and multi-species simulations +1. propose a trial canopy air temperature and humidity; +2. run leaf models against that trial air state; +3. update the trial air state from leaf sensible and latent heat fluxes; +4. repeat until convergence; +5. commit only the accepted canopy or voxel air state to the mutable environment + backend. -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. +Pass non-committing trial state through `run_call!`: -## API and ergonomics +```julia +run_call!(context, :leaf_energy; environment=trial_environment, publish=false) +``` -- 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. +Then commit the accepted state through the model-facing environment API: -## Testing and release engineering +```julia +commit_environment!(context, accepted_environment) +run_call!(context, :leaf_energy; publish=true) +``` -- 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. +The transient state is interpreted by each target backend through its opaque +compiled handle, so one call can sample different cells for different leaves. +`commit_environment!` commits only the accepted state to a mutable backend. +Future work is to validate full voxel, layer, and octree implementations on +this same model-side API. -## 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 +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..67c135dbf 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, +) + +simulation = run!(model) +final_state(simulation, One(scale=:Leaf)).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..2f254e75c 100644 --- a/docs/src/prerequisites/key_concepts.md +++ b/docs/src/prerequisites/key_concepts.md @@ -1,185 +1,108 @@ # 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, each declared as `Required(T)` or + `Default(value)`; +- `outputs_`: values written to object status; +- `environment_inputs_`: values sampled from the environment; +- `environment_outputs_`: environment variables a controller may commit; +- `commit_environment!`: accepted meteorological state committed to a mutable + environment by controller models; +- `dep`: processes called manually by the model, when required. -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, status, environment, constants, context) ``` -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 +`Required(T)` describes an input that must be supplied by object state or +another application. `Default(value)` is a true model fallback that +PlantSimEngine can initialize automatically. Output literals are initial +output-state values. -![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 +- `ModelSpec(...; on=...)` selects target objects; +- `ModelSpec(...; inputs=...)` selects producers for value dependencies; +- `ModelSpec(...; calls=...)` binds manually controlled model calls; +- `ModelSpec(...; every=...)` selects the execution cadence; +- `Environment(...)` configures environment sampling; +- `Updates(...; after=:application_id)` orders intentional additional writers; +- `ModelSpec(...; output_routing=...)` controls output publication. -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. `ModelSpec(...; 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 `ModelSpec(...; 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..907953a44 100644 --- a/docs/src/step_by_step/advanced_coupling.md +++ b/docs/src/step_by_step/advanced_coupling.md @@ -1,64 +1,167 @@ # 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. -This kind of interdependency requires a little more work from the user/modeler for PlantSimEngine to be able to automatically create the dependency graph. +That second case is a manual call dependency. In the composite-model/object API it is +declared with `ModelSpec(...; calls=...)`. -## Declaring hard dependencies +## Soft inputs and manual calls -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. +Use `ModelSpec(...; inputs=...)` or inferred same-object bindings when a model only needs a +value. Use `ModelSpec(...; calls=...)` when the parent model must directly run another model +inside its own `run!` method. -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 example process models in `examples/dummy.jl` contain both patterns: -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`... +- `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. -When run, `Process2Model` calls another process's [`run!`](@ref) function explicitely, which requires defining that process as a hard-dependency of `Process2Model` : +## Declaring manual calls in the scenario -```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 +`ModelSpec(...; calls=...)` is scenario-level wiring. The model kernel remains generic; the +scenario decides which concrete application is called. + +Use `application=...` in scenario-level `ModelSpec(...; calls=...)` and `ModelSpec(...; inputs=...)` when you +know which mounted model application should provide the value or be called. Use +process identities in model-level contracts such as `dep(model)`, where the +model author only declares that a compatible process is required and cannot know +the names chosen by future scenarios. + +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, on=One(scale=:Scene), every=Day(1)), + + ModelSpec(Process1Model(2.0); name=:process1, on=One(scale=:Scene), every=Day(1)), + + ModelSpec(Process2Model(); name=:process2, on=One(scale=:Scene), calls=(:process1 => One(scale=:Scene, application=:process1)), every=Day(1)), + + ModelSpec(Process3Model(); name=:process3, on=One(scale=:Scene), calls=(:process2 => One(scale=:Scene, application=:process2)), every=Day(1)), + + ModelSpec(Process5Model(); name=:process5, on=One(scale=:Scene), every=Day(1)), + + ModelSpec(Process7Model(); name=:process7, on=One(scale=:Scene), every=Day(1)), + + ModelSpec(Process6Model(); name=:process6, on=One(scale=:Scene), every=Day(1)), + ), + environment=meteo_day, +) + +select( + DataFrame(Diagnostics.explain_calls(complex_scene)), + :application_id, + :call, + :callee_application_ids, + :callee_object_ids, + :publication_policy, +) ``` -`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. +Applications selected by `ModelSpec(...; calls=...)` are not scheduled as independent root +applications under their caller. They run only when the parent calls them. +This gives the parent full call-stack control. -!!! 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). +## Running the coupled model -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: +The regular soft dependencies are still inferred from `inputs_` and +`outputs_`. The scheduler combines those soft edges with the call ownership +rules: + +```@example scene_advanced_coupling +select( + DataFrame(Diagnostics.explain_schedule(complex_scene)), + :application_id, + :manual_call_only, + :execution_index, + :clock, +) +``` + +Run one timestep: + +```@example scene_advanced_coupling +complex_sim = run!(complex_scene; steps=1) +complex_status = final_state(complex_sim) +( + var3=complex_status.var3, + var5=complex_status.var5, + var6=complex_status.var6, + var8=complex_status.var8, +) +``` + +## Writing new hard-coupled models + +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!(context, :leaf_energy; 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`. +The result is always vector-like. Retrieve targets without executing them when +an algorithm needs selective execution or an already sampled environment for +each target: -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: +```julia +targets = call_targets(context, :leaf_energy) +for (target, leaf_environment) in zip(targets, environments_by_leaf) + run_call!( + target; + sampled_environment=leaf_environment, + publish=false, + ) +end +``` + +For a provider-aware trial state shared by the call, keep the execute-all form. +Each target still samples through its own compiled handle: ```julia -PlantSimEngine.dep(::Process2Model) = (process1=Process1Model,) +function PlantSimEngine.run!(model::SceneEnergyBalance, status, environment, + constants, context) + trial = trial_environment(model, status) + run_call!(context, :leaf_energy; environment=trial, publish=false) + + accepted = accepted_environment(model, status) + commit_environment!(context, accepted) + run_call!(context, :leaf_energy; publish=true) + + return nothing +end ``` -## Examples in the wild +`run_call!` defaults to `publish=false`, which is useful for trial iterations. +Pass non-committing trial state with the `environment` keyword. Use +`commit_environment!` and `publish=true` for the accepted state so temporal +streams and mutable environment state are published once. + +The MAESPA-style example uses the same mechanism: a model energy-balance model +calls all selected leaf energy-balance models and the shared soil model while +it solves canopy microclimate. -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 `ModelSpec(...; 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..5a762b2dd 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 `ModelSpec(...; on=...)`, connect values with `ModelSpec(...; inputs=...)`, and use +`ModelSpec(...; 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,203 @@ 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) +The model implementation declares the status variables it reads and writes: -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): - -```@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)) -``` - -To know which variables should be initialized, we can use [`to_initialize`](@ref): +### CompositeModel Objects -```@example usepkg -m = ModelMapping(Beer(0.5)) -to_initialize(m) -``` +A `CompositeModel` contains simulated `Object`s. An object can represent a model, plant, +axis, leaf, soil layer, sensor, voxel, or any other simulated entity. -Their values are uninitialized though (hence the warnings): +For a first example, we use one object representing the whole model. The `Beer` +model reads `LAI`, so we initialize that variable on the object status. -```@example usepkg -(m[:LAI], m[:aPPFD]) +```@example detailed_scene +model = CompositeModel( + Beer(0.5); + status=(LAI=2.0,), + environment=meteo_day, + timestep=Day(1), +); +nothing ``` -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(Diagnostics.explain_applications(model)), + :application_id, + :process, + :target_ids, +) ``` -Or after instantiation using [`init_status!`](@ref): +`Beer` has no model-to-model value input in this first model because `LAI` was +initialized directly on the object status: -```@example usepkg -m = ModelMapping(Beer(0.5)) - -init_status!(m, LAI = 2.0) +```@example detailed_scene +Diagnostics.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(Diagnostics.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 - -To make a simulation, we usually need the climatic/meteorological conditions measured close to the object or component. +## Running The Simulation -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). +Run the model with [`run!`](@ref): -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, outputs=:all) +nothing ``` -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). - -## Simulation +Final state is available independently of retained output history: -### 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. - -Your call to the function would then look like this: - -```julia -run!(model_list, meteo) +```@example detailed_scene +scene_status = final_state(sim) +(LAI=scene_status.LAI, aPPFD=scene_status.aPPFD) ``` -The first argument is the model mapping (see [`ModelMapping`](@ref)), and the second defines the micro-climatic conditions. +The returned `Simulation` stores retained output streams: -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 - -For example we can simulate the `light_interception` of a leaf like so: +```@example detailed_scene +first(collect_outputs(sim; sink=nothing), 3) +``` -```@example usepkg -using PlantSimEngine, PlantMeteo, Dates +For a table, use the default `DataFrame` sink: -# Import the examples defined in the `Examples` sub-module -using PlantSimEngine.Examples +```@example detailed_scene +first(collect_outputs(sim), 3) +``` -meteo = Atmosphere(T = 20.0, Wind = 1.0, Rh = 0.65, Ri_PAR_f = 500.0) +## Adding A Model Coupling -leaf = ModelMapping(Beer(0.5), status = (LAI = 2.0,)) +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. -outputs_example = run!(leaf, meteo) +```@example detailed_scene +coupled_scene = CompositeModel( + ToyDegreeDaysCumulModel(), + ToyLAIModel(), + Beer(0.5); + status=(TT_cu=0.0,), + environment=meteo_day, + timestep=Day(1), +) -outputs_example[:aPPFD] +select( + DataFrame(Diagnostics.explain_bindings(coupled_scene)), + :application_id, + :input, + :source_application_ids, + :carrier_kind, + :copy_semantics, +) ``` -### Outputs +The `LAI` binding uses a live reference carrier, so the light-interception +model sees the value written by the LAI model without copying it. -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. +Run the coupled model: -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`. +```@example detailed_scene +coupled_sim = run!(coupled_scene; steps=5, outputs=:all) +first(collect_outputs(coupled_sim), 8) +``` -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: +The final object status contains the latest values from the coupled models: -```@setup usepkg -outputs_example +```@example detailed_scene +coupled_status = final_state(coupled_sim) +(TT_cu=coupled_status.TT_cu, LAI=coupled_status.LAI, aPPFD=coupled_status.aPPFD) ``` -We can extract the value of one variable by indexing into it, *e.g.* for the intercepted light: +## What Needs Initialization? -```@example usepkg -outputs_example[:aPPFD] -``` +Model `inputs_(...)` explicitly distinguishes `Required(T)` from +`Default(value)`. A required input needs user state or a producer binding; a +defaulted input needs neither. In a coupled model, an upstream application can +satisfy a required input. -Or similarly using the dot syntax: +Use the compiler explanations to distinguish the two cases: -```@example usepkg -outputs_example.aPPFD -``` +- `:supplied` means the object `Status` already provides the value; +- `:producer_bound` means another application supplies it; +- `:defaulted` means `Default(value)` initialized it; +- `:required` means it still has no source and compilation will fail. -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. +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: -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: +```@example detailed_scene +bad_scene = CompositeModel( + ToyLAIModel(); + environment=meteo_day, +) -```@example usepkg -using DataFrames -convert_outputs(outputs_example, DataFrame) +try + Diagnostics.explain_bindings(bad_scene) +catch err + first(sprint(showerror, err), 300) +end ``` -## Model coupling - -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..a3f3b6613 100644 --- a/docs/src/step_by_step/implement_a_model.md +++ b/docs/src/step_by_step/implement_a_model.md @@ -30,37 +30,31 @@ Declare the `inputs_` and `outputs_` methods for that model (note the '_', these ```@example usepkg function PlantSimEngine.inputs_(::Beer) - (LAI=-Inf,) + (LAI=Required(Float64),) end function PlantSimEngine.outputs_(::Beer) - (aPPFD=-Inf,) + (aPPFD=0.0,) end ``` Write the [`run!`](@ref) function that operates on a single timestep : ```@example usepkg -function run!(::Beer, models, status, meteo, constants, extras) - status.PPFD = - meteo.Ri_PAR_f * - exp(-models.light_interception.k * status.LAI) * +function PlantSimEngine.run!(model::Beer, status, environment, constants, context) + status.aPPFD = + environment.Ri_PAR_f * + exp(-model.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) -Some extra utility functions can also be interesting to implement to make users' lives simpler. See the [Model implementation additional notes](@ref) page for details. +Some context utility functions can also be interesting to implement to make users' lives simpler. See the [Model implementation additional notes](@ref) page for details. If your custom model needs to handle more complex couplings than the simple input/output described in this example, check out the [Coupling more complex models](@ref) page. ## Detailed version @@ -147,87 +141,101 @@ Parameterized types are practical because they let the user choose the type of t ### Inputs and outputs -When implementing a new model, it is necessary to declare what variables will be required, whether provided as an input to our model or computed for every timestep as an output. Input variables will either be initialized by the user in a `Status` object, or provided by another model. Output variables may be global simulation outputs and/or used by other models. +When implementing a new model, it is necessary to declare what variables it +reads and what variables it computes. Every input declaration must say whether +the input is required or has a genuine model default. A required input is +initialized by the user in a `Status` object or bound from another model. A +defaulted input needs neither. Output variables may be retained as simulation +outputs and/or used by other models. In our case, the `Beer` model, computing light interception, has one input variable and one output variable: - Inputs: `:LAI`, the leaf area index (m² m⁻²) - Outputs: `:aPPFD`, the photosynthetic photon flux density (μmol m⁻² s⁻¹) -We declare these inputs/outputs by adding a method for the [`inputs`](@ref) and [`outputs`](@ref) functions. These functions take the type of the model as argument, and return a `NamedTuple` with the names of the variables as keys, and their default values as values: +We declare these inputs/outputs by adding methods for the underscore extension +functions. `inputs_` returns a `NamedTuple` whose values are `Required(T)` or +`Default(value)` declarations. `outputs_` returns a `NamedTuple` whose values +are the initial output state: ```@example usepkg function PlantSimEngine.inputs_(::Beer) - (LAI=-Inf,) + (LAI=Required(Float64),) end function PlantSimEngine.outputs_(::Beer) - (aPPFD=-Inf,) + (aPPFD=0.0,) end ``` -These functions are internal, and end with an "\_". Users instead use [`inputs`](@ref) and [`outputs`](@ref) to query model variables. +`LAI` has no scientifically meaningful fallback, so it is required. If the +model instead had an optional efficiency of `0.8`, it would declare +`efficiency=Default(0.8)`. Do not use sentinel values such as `-Inf` to mean +"required": they are ordinary values and hide the model contract. + +`Required(Float64)` is an expected type, not an initialization value. A model +that supports a broader or parameterized type can declare that type instead. +PlantSimEngine does not convert status values to `Float64`. + +These extension functions end with an "\_". Simulation users instead use +[`inputs`](@ref), [`outputs`](@ref), [`init_variables`](@ref), and +[`Diagnostics.explain_initialization`](@ref) to inspect the contract. ### 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 five arguments: ```julia -function run!(::Beer, models, status, meteo, constants, extras) +function PlantSimEngine.run!(model::Beer, status, environment, constants, context) ``` -- the model's type -- models: a [`ModelMapping`](@ref) object, which contains all the models of the simulation +- model: the current model instance, used for dispatch and parameter access. - 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) +- environment: the sampled model-facing environment for the current target and + timestep. - 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 +- context: PlantSimEngine's runtime context for hard calls and lifecycle + operations. -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 = - meteo.Ri_PAR_f * - exp(-models.light_interception.k * status.LAI) * +function PlantSimEngine.run!(model::Beer, status, environment, constants, context) + status.aPPFD = + environment.Ri_PAR_f * + exp(-model.k * status.LAI) * constants.J_to_umol + return nothing end ``` ### Additional notes -To use this model, users will have to make sure that the variables for that model are defined in the [`Status`](@ref) object, the meteorology, and the `Constants` object. +To use this model, simulation users must supply or bind every `Required` status +input. Inputs declared with `Default` are initialized automatically. Required +environment variables and constants must also be available through their +respective contracts. !!! 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 read directly from the current model instance. For +example, the `k` parameter of the `Beer` model is `model.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 +253,14 @@ 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 `ModelSpec(...; inputs=...)` or +`ModelSpec(...; 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..10a65fb9f 100644 --- a/docs/src/step_by_step/model_switching.md +++ b/docs/src/step_by_step/model_switching.md @@ -1,102 +1,98 @@ # 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**. +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. -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. +In the composite-model/object API, the switch happens at the model-application layer: +replace the model inside a `ModelSpec`, keep the same `ModelSpec(...; on=...)` +selector, and keep the same input contract when the replacement model needs the +same variables. -## A first simulation as a starting point +## A first simulation -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: +This model computes degree-days, LAI, absorbed PAR, and growth on one model +object: -Importing the models from the scripts: +```@example scene_model_switching +function plant_model_with_growth(growth_model; growth_name=:growth) + CompositeModel( + Object(:scene; scale=:Scene, kind=:scene); + applications=( + ModelSpec(ToyDegreeDaysCumulModel(); name=:degree_days, on=One(scale=:Scene), every=Day(1)), -```julia -using PlantSimEngine -# Import the examples defined in the `Examples` sub-module: -using PlantSimEngine.Examples -``` + ModelSpec(ToyLAIModel(); name=:lai, on=One(scale=:Scene), every=Day(1)), -Coupling the models in a [`ModelMapping`](@ref): + ModelSpec(Beer(0.5); name=:light_interception, on=One(scale=:Scene), every=Day(1)), -```@example usepkg -models = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(0.2), - status=(TT_cu=cumsum(meteo_day.TT),), -) + ModelSpec(growth_model; name=growth_name, on=One(scale=:Scene), every=Day(1)), + ), + environment=meteo_day, + ) +end -nothing # hide +rue_scene = plant_model_with_growth(ToyRUEGrowthModel(0.2)) +rue_sim = run!(rue_scene; steps=10) +rue_status = final_state(rue_sim) +(growth_model=:ToyRUEGrowthModel, biomass=rue_status.biomass) ``` -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 +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(Diagnostics.explain_bindings(rue_scene)), + :application_id, + :input, + :source_application_ids, + :origin, + :carrier_kind, +) ``` -## 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 = final_state(assim_sim) +( + 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(Diagnostics.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. 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..be48327f0 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,121 @@ -# 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 `ModelSpec(...; on=...)` and `ModelSpec(...; 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 +## LAI And Light Interception -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 +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. -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -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(Diagnostics.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 = final_state(growth_sim) +(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), +) -Several example simulations are provided there. Here's one taken from [this page](https://vezy.github.io/PlantBiophysics.jl/stable/simulation/first_simulation/) : +requested_sim = run!( + growth_scene; + steps=5, + outputs=request, +) -```julia -using PlantBiophysics, PlantSimEngine +first(collect_outputs(requested_sim, :biomass_daily), 5) +``` -meteo = Atmosphere(T = 22.0, Wind = 0.8333, P = 101.325, Rh = 0.4490995) +## PlantBiophysics -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) - ) +The same composite-model/object API can host models from companion packages such as +PlantBiophysics. A typical PlantBiophysics energy-balance setup uses +`ModelSpec(...; calls=...)` so an iterative parent model can manually run photosynthesis and +stomatal-conductance models, then call `run_call!(target; publish=true)` once +for the accepted solution. -out = run!(leaf,meteo) -``` \ No newline at end of file +See [MAESPA-style model example handoff](../dev/maespa_model_handoff.md) for +the current multi-plant energy-balance acceptance example. diff --git a/docs/src/step_by_step/simple_model_coupling.md b/docs/src/step_by_step/simple_model_coupling.md index 179b36d6f..56882d6b2 100644 --- a/docs/src/step_by_step/simple_model_coupling.md +++ b/docs/src/step_by_step/simple_model_coupling.md @@ -1,127 +1,97 @@ # 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` . - -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. +light_sim = run!(light_scene; steps=3, outputs=:all) +first(collect_outputs(light_sim; sink=DataFrame), 3) +``` -Here's a first attempt : +## Coupling two models -```@example usepkg -using PlantSimEngine -# Import the examples defined in the `Examples` sub-module: -using PlantSimEngine.Examples +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: -# 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(Diagnostics.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 : - -```@example usepkg -using PlantSimEngine - -# PlantMeteo and CSV packages are now used -using PlantMeteo, Dates +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. -# Import the examples defined in the `Examples` sub-module: -using PlantSimEngine.Examples - -# Import example weather data -meteo_day = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"), duration=Dates.Day) - -# A ModelMapping with two coupled models -models = ModelMapping( - ToyLAIModel(), - Beer(0.5), - status=(TT_cu=cumsum(meteo_day.TT),), # We can now compute a genuine cumulative thermal time from the weather data -) +Run the coupled model: -# Add the weather data to the run! call -outputs_coupled = run!(models, meteo_day) -outputs_coupled[1:3,:] +```@example scene_coupling +coupled_sim = run!(coupled_scene; steps=5) +coupled_status = final_state(coupled_sim) +(TT_cu=coupled_status.TT_cu, LAI=coupled_status.LAI, aPPFD=coupled_status.aPPFD) ``` -And there you have it. The light interception model made its computations using the Leaf Area Index computed by ToyLAIModel. +## Adding another model -## Further coupling +Additional models are just additional applications. `ToyRUEGrowthModel` +consumes `aPPFD`, which is produced by `Beer`, so the compiler infers another +same-object binding: -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( +```@example scene_coupling +growth_scene = CompositeModel( + ToyDegreeDaysCumulModel(), ToyLAIModel(), Beer(0.5), - ToyRUEGrowthModel(0.2), - status=(TT_cu=cumsum(meteo_day.TT),), + ToyRUEGrowthModel(0.2); + environment=meteo_day, ) -nothing # hide -``` \ No newline at end of file +growth_sim = run!(growth_scene; steps=5) +growth_status = final_state(growth_sim) +(LAI=growth_status.LAI, aPPFD=growth_status.aPPFD, biomass=growth_status.biomass) +``` diff --git a/docs/src/troubleshooting/common_errors.md b/docs/src/troubleshooting/common_errors.md new file mode 100644 index 000000000..a8df3b70d --- /dev/null +++ b/docs/src/troubleshooting/common_errors.md @@ -0,0 +1,13 @@ +# Common Errors + +A missing-input error means a `Required(T)` input has neither supplied state +nor a producer binding; start with `Diagnostics.explain_initialization`. A +plain-input-declaration error means `inputs_` must replace each literal with +`Required(T)` or `Default(value)`. A cardinality error lists selector matches; +correct the scope or choose the intended `OptionalOne`/`Many` multiplicity. An +ambiguity requires an explicit application/object selector. + +Duplicate-writer errors require either distinct output routing or an explicit +`Updates` order. Cadence errors require fixed `Dates` periods compatible with +the environment base step. Extend package functions as +`PlantSimEngine.run!(...)`, including the package qualification. diff --git a/docs/src/troubleshooting/dependency_cycles.md b/docs/src/troubleshooting/dependency_cycles.md new file mode 100644 index 000000000..312321816 --- /dev/null +++ b/docs/src/troubleshooting/dependency_cycles.md @@ -0,0 +1,29 @@ +# 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 +ModelSpec( + LeafModel(); + 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..7e5fda2bd --- /dev/null +++ b/docs/src/troubleshooting/runtime_contracts.md @@ -0,0 +1,12 @@ +# Runtime Contracts And Diagnostics + +Use `Diagnostics.explain_initialization`, `Diagnostics.explain_bindings`, `Diagnostics.explain_calls`, +`Diagnostics.explain_schedule`, `Diagnostics.explain_environment_bindings`, and +`Diagnostics.explain_output_retention` as the supported inspection surface. Do not inspect +compiled internal fields. + +Targets, carriers, calls, writer checks, and schedules refresh after the +application that made a structural change. New objects join applications still +remaining in that timestep; they do not retroactively run earlier applications. +Movement and geometry changes invalidate affected spatial environment bindings. +Accepted streams are append-only. 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..37bdc6418 --- /dev/null +++ b/docs/src/tutorials/growing_plant/part1_growth.md @@ -0,0 +1,33 @@ +# Growing A Plant CompositeModel + +Begin with a plant object and leaf objects whose carbon production is gathered +by a plant application through `Many(scale=:Leaf, within=Subtree())`. A growth +model calls `register_object!` after its carbon or thermal threshold is met. + +Structural changes refresh compiled targets after the application that made +the change. A new leaf may run applications that remain later in the same +timestep, but it never retroactively runs applications that already completed. +When callers mutate structure between `step!` calls, refresh occurs before the +next step. + +Build the initial registry explicitly so ownership remains visible: + +```julia +model = CompositeModel( + Object(:plant; scale=:Plant, status=Status(carbon=0.0)), + Object(:leaf_1; scale=:Leaf, parent=:plant, status=Status(area=1.0)); + applications=(leaf_application, plant_balance, growth_application), + environment=weather, +) +``` + +The plant balance gathers leaf production with +`Many(scale=:Leaf, within=Subtree())`. The growth kernel obtains the live model +with `runtime_model(context)`, checks its carbon and thermal thresholds, creates +a fully initialized `Object`, and calls `register_object!`. It should deduct +the construction cost exactly once before registration. + +After each step, assert both biology and structure: remaining plant carbon, +the number of leaf objects, each new leaf's parent, and accepted historical +outputs. `Diagnostics.explain_applications` should show that the new leaf is absent +during its creation step and present after the between-step refresh. 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..9ab2f3a90 --- /dev/null +++ b/docs/src/tutorials/growing_plant/part2_roots_water.md @@ -0,0 +1,37 @@ +# 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 +ModelSpec( + PlantWaterModel(); + 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 `Diagnostics.input_value` or `Diagnostics.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..1c4c6bbf7 --- /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. `Diagnostics.explain_initialization(model)` for missing state or environment values. +2. `Diagnostics.explain_bindings(model)` for source scope and multiplicity. +3. `Diagnostics.explain_writers(model)` for competing canonical outputs. +4. `Diagnostics.explain_calls(model)` for call-only targets and target cardinality. +5. `Diagnostics.explain_schedule(model)` for cadence and root ordering. +6. `Diagnostics.explain_outputs(simulation)` after execution for publication history. + +A trial call must not mutate accepted output history or scatter mutable +environment outputs. Nested trials inherit the outer publication decision. +Convergence and failure policy belongs to the parent model: it decides the +iteration limit, tolerance, fallback, and whether any state is accepted. + +Structural mutation is also transactional at the timestep boundary. A new +organ is registered immediately in the model registry but does not recursively +run 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..1473f1625 100644 --- a/docs/src/working_with_data/fitting.md +++ b/docs/src/working_with_data/fitting.md @@ -1,76 +1,57 @@ -# 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.Evaluation.fit` is the shared interface for model-specific +calibration. +Model packages implement a method whose first argument is the model type and +whose second argument is Tables.jl-compatible observations. ```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.Evaluation.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, +) + +simulation = run!(model) +leaf = final_state(simulation, One(scale=:Leaf)) +data = DataFrame( + aPPFD=[leaf.aPPFD], + LAI=[leaf.LAI], + Ri_PAR_f=[meteo.Ri_PAR_f[1]], +) + +PlantSimEngine.Evaluation.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/docs/test/runtests.jl b/docs/test/runtests.jl new file mode 100644 index 000000000..73b4f265c --- /dev/null +++ b/docs/test/runtests.jl @@ -0,0 +1,42 @@ +using Test + +@testset "Progressive journey structure" begin + journey_root = joinpath(@__DIR__, "..", "src", "journeys") + user_pages = sort([ + joinpath(journey_root, "users", file) + for file in readdir(joinpath(journey_root, "users")) + if endswith(file, ".md") + ]) + modeler_pages = sort([ + joinpath(journey_root, "modelers", file) + for file in readdir(joinpath(journey_root, "modelers")) + if endswith(file, ".md") + ]) + + for page in user_pages + source = read(page, String) + @test occursin("New concept", source) + @test occursin("## Page recap", source) + @test occursin("**You added:**", source) + @test occursin("**PlantSimEngine infer", source) + @test occursin("**You keep explicit:**", source) + @test occursin("**New API names:**", source) + @test !occursin("```julia", source) + end + + for page in modeler_pages + source = read(page, String) + @test occursin("**New concept:**", source) + @test occursin("## Model-author recap", source) + @test occursin("**You implemented:**", source) + @test occursin("**PlantSimEngine inferred:**", source) + @test occursin("**The scenario author keeps explicit:**", source) + @test occursin("**New API names:**", source) + @test occursin("tested", lowercase(source)) + end +end + +@testset "PlantSimEngine documentation" begin + ENV["PLANTSIMENGINE_DOCS_BUILD_ONLY"] = "true" + @test include(joinpath(@__DIR__, "..", "make.jl")) === nothing +end diff --git a/examples/Beer.jl b/examples/Beer.jl index c7069c536..bc6279359 100644 --- a/examples/Beer.jl +++ b/examples/Beer.jl @@ -12,7 +12,7 @@ PlantSimEngine.@process "light_interception" verbose = false Beer-Lambert law for light interception. Required inputs: `LAI` in m² m⁻². -Required meteorology data: `Ri_PAR_f`, the incident flux of atmospheric radiation in the +Required environment input: `Ri_PAR_f`, the incident flux of atmospheric radiation in the PAR, in W m[soil]⁻² (== J m[soil]⁻² s⁻¹). Output: aPPFD, the absorbed Photosynthetic Photon Flux Density in μmol[PAR] m[leaf]⁻² s⁻¹. @@ -21,12 +21,9 @@ 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) + run!(model::Beer, status, environment, constants, context) Computes the photosynthetic photon flux density (`aPPFD`, µmol m⁻² s⁻¹) absorbed by an object using the incoming PAR radiation flux (`Ri_PAR_f`, W m⁻²) and the Beer-Lambert law @@ -34,41 +31,50 @@ 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. -- `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 -- `extra = nothing`: extra arguments, not used here. +- `model`: the current Beer model instance. +- `status`: the application-local view of the target [`Object`](@ref) status. +- `environment`: sampled environment, such as an [`Atmosphere`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.Atmosphere) row. +- `constants`: physical constants supplied by the [`CompositeModel`](@ref) run. +- `context`: runtime context; this kernel does not use it. # 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) +function PlantSimEngine.run!(model::Beer, status, environment, constants, context) status.aPPFD = - meteo.Ri_PAR_f * - (1.0 - exp(-models.light_interception.k * status.LAI)) * + environment.Ri_PAR_f * + (1.0 - exp(-model.k * status.LAI)) * constants.J_to_umol end function PlantSimEngine.inputs_(::Beer) - (LAI=-Inf,) + (LAI=Required(Real),) end -function PlantSimEngine.outputs_(::Beer) - (aPPFD=-Inf,) +function PlantSimEngine.outputs_(model::Beer) + (aPPFD=oftype(float(model.k), -Inf),) end +PlantSimEngine.environment_inputs_(::Beer) = (Ri_PAR_f=0.0,) + """ fit(::Type{Beer}, df; J_to_umol=PlantMeteo.Constants().J_to_umol) @@ -92,17 +98,27 @@ using PlantSimEngine using PlantSimEngine.Examples ``` -Create a model list with a Beer model, and fit it to the data: +Create a `CompositeModel` with one leaf object, then fit `Beer` 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]) -fit(Beer, df) +model = CompositeModel( + Beer(0.6); + status=(LAI=2.0,), + id=:leaf, + scale=:Leaf, + environment=environment, +) +simulation = run!(model) +leaf = final_state(simulation, One(scale=:Leaf)) +df = DataFrame(aPPFD=leaf.aPPFD, LAI=leaf.LAI, Ri_PAR_f=environment.Ri_PAR_f[1]) +Evaluation.fit(Beer, df) ``` """ -function PlantSimEngine.fit(::Type{Beer}, df; J_to_umol=PlantMeteo.Constants().J_to_umol) +function PlantSimEngine.Evaluation.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/ToyAdvancedControl.jl b/examples/ToyAdvancedControl.jl new file mode 100644 index 000000000..22c5da86e --- /dev/null +++ b/examples/ToyAdvancedControl.jl @@ -0,0 +1,121 @@ +PlantSimEngine.@process "toy_selective_call_controller" verbose = false +PlantSimEngine.@process "toy_stock_writer" verbose = false + +""" + ToySelectiveCallControllerModel( + trial_temperatures, + accepted_temperature; + selected_object, + ) + +Resolve several hard-call targets, run `selected_object` for several +unpublished trials, then publish one accepted result. +""" +struct ToySelectiveCallControllerModel{T} <: + AbstractToy_Selective_Call_ControllerModel + trial_temperatures::NTuple{2,T} + accepted_temperature::T + selected_object::Symbol +end + +function ToySelectiveCallControllerModel( + trial_temperatures::Tuple, + accepted_temperature, + ; + selected_object, +) + length(trial_temperatures) == 2 || error( + "ToySelectiveCallControllerModel needs exactly two trial temperatures.", + ) + values = promote( + float(trial_temperatures[1]), + float(trial_temperatures[2]), + float(accepted_temperature), + ) + T = typeof(values[1]) + return ToySelectiveCallControllerModel{T}( + (values[1], values[2]), + values[3], + Symbol(selected_object), + ) +end + +PlantSimEngine.inputs_(::ToySelectiveCallControllerModel) = NamedTuple() +PlantSimEngine.dep(::ToySelectiveCallControllerModel) = ( + readers=Call(Many( + scale=:Leaf, + process=:toy_environment_reader, + within=Subtree(), + )), +) +function PlantSimEngine.outputs_(model::ToySelectiveCallControllerModel) + initial = zero(model.accepted_temperature) + return ( + target_count=0, + trial_temperature_seen=initial, + accepted_temperature_seen=initial, + ) +end + +function PlantSimEngine.run!( + model::ToySelectiveCallControllerModel, + status, + environment, + constants, + context, +) + targets = call_targets(context, :readers) + status.target_count = length(targets) + selected = only(call_targets( + context, + :readers; + objects=(ObjectId(model.selected_object),), + )) + + for temperature in model.trial_temperatures + run_call!( + selected; + sampled_environment=(T=temperature,), + publish=false, + ) + end + status.trial_temperature_seen = selected.status.temperature_seen + + run_call!( + selected; + sampled_environment=(T=model.accepted_temperature,), + publish=true, + ) + status.accepted_temperature_seen = selected.status.temperature_seen + return nothing +end + +""" + ToyStockWriterModel(value) + +Write one configured stock value. Several named applications of this model can +demonstrate canonical writer ordering and stream-only output routing. +""" +struct ToyStockWriterModel{T} <: AbstractToy_Stock_WriterModel + value::T +end + +function ToyStockWriterModel(value::Real) + parameter = float(value) + return ToyStockWriterModel{typeof(parameter)}(parameter) +end + +PlantSimEngine.inputs_(::ToyStockWriterModel) = NamedTuple() +PlantSimEngine.outputs_(model::ToyStockWriterModel) = + (stock=zero(model.value),) + +function PlantSimEngine.run!( + model::ToyStockWriterModel, + status, + environment, + constants, + context, +) + status.stock = model.value + return nothing +end diff --git a/examples/ToyAssimGrowthModel.jl b/examples/ToyAssimGrowthModel.jl index cda5f167f..7c52ea6ca 100644 --- a/examples/ToyAssimGrowthModel.jl +++ b/examples/ToyAssimGrowthModel.jl @@ -41,31 +41,37 @@ end # Define inputs: function PlantSimEngine.inputs_(::ToyAssimGrowthModel) - (aPPFD=-Inf,) + (aPPFD=Required(Real),) end # Define outputs: -function PlantSimEngine.outputs_(::ToyAssimGrowthModel) - (carbon_assimilation=-Inf, Rm=-Inf, Rg=-Inf, biomass_increment=-Inf, biomass=0.0) +function PlantSimEngine.outputs_(model::ToyAssimGrowthModel) + initial = oftype(model.LUE, -Inf) + return ( + carbon_assimilation=initial, + Rm=initial, + Rg=initial, + biomass_increment=initial, + biomass=zero(model.LUE), + ) end # Tells Julia what is the type of elements: Base.eltype(x::ToyAssimGrowthModel{T}) where {T} = T # Implement the growth model: -function PlantSimEngine.run!(::ToyAssimGrowthModel, models, status, meteo, constants, extra) +function PlantSimEngine.run!(model::ToyAssimGrowthModel, status, environment, constants, context) # The assimilation is simply the absorbed photosynthetic photon flux density (aPPFD) times the light use efficiency (LUE): - status.carbon_assimilation = status.aPPFD * models.growth.LUE + status.carbon_assimilation = status.aPPFD * model.LUE # The maintenance respiration is simply a factor of the assimilation: - status.Rm = status.carbon_assimilation * models.growth.Rm_factor - # Note that we use models.growth.Rm_factor to access the parameter of the model + status.Rm = status.carbon_assimilation * model.Rm_factor # Net primary productivity of the plant (NPP) is the assimilation minus the maintenance respiration: NPP = status.carbon_assimilation - status.Rm # The NPP is used with a cost (growth respiration Rg): - status.Rg = 1 - (NPP / models.growth.Rg_cost) + status.Rg = 1 - (NPP / model.Rg_cost) # The biomass increment is the NPP minus the growth respiration: status.biomass_increment = NPP - status.Rg @@ -73,6 +79,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..84eee12db 100644 --- a/examples/ToyAssimModel.jl +++ b/examples/ToyAssimModel.jl @@ -39,24 +39,19 @@ end # Define inputs: function PlantSimEngine.inputs_(::ToyAssimModel) - (aPPFD=-Inf, soil_water_content=-Inf) + (aPPFD=Required(Real), soil_water_content=Required(Real)) end # Define outputs: -function PlantSimEngine.outputs_(::ToyAssimModel) - (carbon_assimilation=-Inf,) +function PlantSimEngine.outputs_(model::ToyAssimModel) + (carbon_assimilation=oftype(float(model.LUE), -Inf),) end # Tells Julia what is the type of elements: Base.eltype(::ToyAssimModel{T}) where {T} = T # Implement the model: -function PlantSimEngine.run!(::ToyAssimModel, models, status, meteo, constants, extra) +function PlantSimEngine.run!(model::ToyAssimModel, status, environment, constants, context) # 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 + status.carbon_assimilation = status.aPPFD * model.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..a14c4b754 100644 --- a/examples/ToyCAllocationModel.jl +++ b/examples/ToyCAllocationModel.jl @@ -31,7 +31,11 @@ struct ToyCAllocationModel <: AbstractCarbon_AllocationModel end # Define inputs: function PlantSimEngine.inputs_(::ToyCAllocationModel) - (carbon_assimilation=[-Inf], Rm=-Inf, carbon_demand=[-Inf],) + ( + carbon_assimilation=Required(AbstractVector{<:Real}), + Rm=Required(Real), + carbon_demand=Required(AbstractVector{<:Real}), + ) end # Define outputs: @@ -39,7 +43,7 @@ function PlantSimEngine.outputs_(::ToyCAllocationModel) (carbon_offer=-Inf, carbon_allocation=[-Inf],) end -function PlantSimEngine.run!(::ToyCAllocationModel, models, status, meteo, constants, extra_args) +function PlantSimEngine.run!(::ToyCAllocationModel, status, environment, constants, context) carbon_demand_tot = sum(status.carbon_demand) #Note: this model is multiscale, so status.carbon_demand, status.carbon_allocation, and status.carbon_assimilation are vectors. @@ -68,5 +72,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..ce2b36c9a 100644 --- a/examples/ToyCBiomassModel.jl +++ b/examples/ToyCBiomassModel.jl @@ -28,19 +28,21 @@ end # Define inputs: function PlantSimEngine.inputs_(::ToyCBiomassModel) - (carbon_allocation=-Inf,) + (carbon_allocation=Required(Real),) end # Define outputs: -function PlantSimEngine.outputs_(::ToyCBiomassModel) - (carbon_biomass_increment=-Inf, carbon_biomass=0.0, growth_respiration=-Inf,) +function PlantSimEngine.outputs_(model::ToyCBiomassModel) + initial = oftype(float(model.construction_cost), -Inf) + return ( + carbon_biomass_increment=initial, + carbon_biomass=zero(float(model.construction_cost)), + growth_respiration=initial, + ) end -function PlantSimEngine.run!(m::ToyCBiomassModel, models, status, meteo, constants, extra_args) +function PlantSimEngine.run!(m::ToyCBiomassModel, status, environment, constants, context) status.carbon_biomass_increment = status.carbon_allocation / m.construction_cost 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..1548840c5 100644 --- a/examples/ToyCDemandModel.jl +++ b/examples/ToyCDemandModel.jl @@ -31,29 +31,28 @@ end # Instantiate the `struct` with keyword arguments and default values: function ToyCDemandModel(; optimal_biomass, development_duration) - ToyCDemandModel(optimal_biomass, development_duration) + parameters = promote(float(optimal_biomass), float(development_duration)) + return ToyCDemandModel(parameters...) end # Define inputs: function PlantSimEngine.inputs_(::ToyCDemandModel) - (TT=-Inf,) + (TT=Required(Real),) end # Define outputs: -function PlantSimEngine.outputs_(::ToyCDemandModel) - (carbon_demand=-Inf,) +function PlantSimEngine.outputs_(model::ToyCDemandModel) + (carbon_demand=oftype(model.optimal_biomass, -Inf),) end # Tells Julia what is the type of elements: Base.eltype(::ToyCDemandModel{T}) where {T} = T # Implement the growth model: -function PlantSimEngine.run!(::ToyCDemandModel, models, status, meteo, constants, extra) +function PlantSimEngine.run!(model::ToyCDemandModel, status, environment, constants, context) # 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 + status.carbon_demand = + status.TT * + model.optimal_biomass / + model.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..95d59565a 100644 --- a/examples/ToyDegreeDays.jl +++ b/examples/ToyDegreeDays.jl @@ -11,26 +11,28 @@ Computes the thermal time in degree days and cumulated degree-days based on the the initial cumulated degree days, the base temperature below which there is no growth, and the maximum temperature for growh. """ -struct ToyDegreeDaysCumulModel <: AbstractDegreedaysModel - init_TT::Float64 - T_base::Float64 - T_max::Float64 +struct ToyDegreeDaysCumulModel{T<:Real} <: AbstractDegreedaysModel + init_TT::T + T_base::T + T_max::T end # Defining default values: -ToyDegreeDaysCumulModel(; init_TT=0.0, T_base=10.0, T_max=43.0) = ToyDegreeDaysCumulModel(init_TT, T_base, T_max) +function ToyDegreeDaysCumulModel(; init_TT=0.0, T_base=10.0, T_max=43.0) + parameters = promote(float(init_TT), float(T_base), float(T_max)) + return ToyDegreeDaysCumulModel(parameters...) +end # Defining the inputs and outputs of the model: PlantSimEngine.inputs_(::ToyDegreeDaysCumulModel) = NamedTuple() -PlantSimEngine.outputs_(m::ToyDegreeDaysCumulModel) = (TT=-Inf, TT_cu=0.0,) +PlantSimEngine.outputs_(m::ToyDegreeDaysCumulModel) = ( + TT=oftype(m.init_TT, -Inf), + TT_cu=m.init_TT, +) +PlantSimEngine.environment_inputs_(m::ToyDegreeDaysCumulModel) = (T=zero(m.T_base),) # Implementing the actual algorithm by adding a method to the run! function for our model: -function PlantSimEngine.run!(m::ToyDegreeDaysCumulModel, models, status, meteo, constants=nothing, extra=nothing) - status.TT = max(0.0, min(meteo.T, m.T_max) - m.T_base) +function PlantSimEngine.run!(m::ToyDegreeDaysCumulModel, status, environment, constants, context) + status.TT = max(zero(m.T_base), min(environment.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..0c7bcd821 100644 --- a/examples/ToyLAIModel.jl +++ b/examples/ToyLAIModel.jl @@ -26,51 +26,56 @@ Computes the Leaf Area Index (LAI) based on a sigmoid function of thermal time. - `LAI`: the Leaf Area Index, usually in m² m⁻² """ -struct ToyLAIModel <: AbstractLai_DynamicModel - max_lai::Float64 - dd_incslope::Int - inc_slope::Float64 - dd_decslope::Int - dec_slope::Float64 +struct ToyLAIModel{T<:Real} <: AbstractLai_DynamicModel + max_lai::T + dd_incslope::T + inc_slope::T + dd_decslope::T + dec_slope::T end # Defining a method with keyword arguments and default values: -ToyLAIModel(; max_lai=8.0, dd_incslope=800, inc_slope=110, dd_decslope=1500, dec_slope=20) = ToyLAIModel(max_lai, dd_incslope, inc_slope, dd_decslope, dec_slope) +function ToyLAIModel(; max_lai=8.0, dd_incslope=800, inc_slope=110, dd_decslope=1500, dec_slope=20) + parameters = promote( + float(max_lai), + float(dd_incslope), + float(inc_slope), + float(dd_decslope), + float(dec_slope), + ) + return ToyLAIModel(parameters...) +end # Defining the inputs and outputs of the model: -PlantSimEngine.inputs_(::ToyLAIModel) = (TT_cu=-Inf,) -PlantSimEngine.outputs_(::ToyLAIModel) = (LAI=-Inf,) +PlantSimEngine.inputs_(::ToyLAIModel) = (TT_cu=Required(Real),) +PlantSimEngine.outputs_(model::ToyLAIModel) = (LAI=oftype(model.max_lai, -Inf),) # Implementing the actual algorithm by adding a method to the run! function for our model: -function PlantSimEngine.run!(::ToyLAIModel, models, status, meteo, constants=nothing, extra=nothing) +function PlantSimEngine.run!(model::ToyLAIModel, status, environment, constants, context) status.LAI = - models.LAI_Dynamic.max_lai * - (1.0 / - (1.0 + exp((models.LAI_Dynamic.dd_incslope - status.TT_cu) / models.LAI_Dynamic.inc_slope)) - - 1.0 / (1.0 + exp((models.LAI_Dynamic.dd_decslope - status.TT_cu) / models.LAI_Dynamic.dec_slope)) + model.max_lai * + (one(model.max_lai) / + (one(model.max_lai) + exp((model.dd_incslope - status.TT_cu) / model.inc_slope)) - + one(model.max_lai) / (one(model.max_lai) + exp((model.dd_decslope - status.TT_cu) / model.dec_slope)) ) - if status.LAI < 0.0 - status.LAI = 0.0 + if status.LAI < zero(model.max_lai) + status.LAI = zero(model.max_lai) 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 +83,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 @@ -86,14 +91,19 @@ struct ToyLAIfromLeafAreaModel{T} <: AbstractLai_DynamicModel end # Defining the inputs and outputs of the model: -PlantSimEngine.inputs_(::ToyLAIfromLeafAreaModel) = (plant_surfaces=[-Inf],) -PlantSimEngine.outputs_(::ToyLAIfromLeafAreaModel) = (LAI=-Inf, total_surface=-Inf) +PlantSimEngine.inputs_(::ToyLAIfromLeafAreaModel) = ( + plant_surfaces=Required(AbstractVector{<:Real}), +) +PlantSimEngine.outputs_(m::ToyLAIfromLeafAreaModel) = ( + LAI=oftype(float(m.scene_area), -Inf), + total_surface=oftype(float(m.scene_area), -Inf), +) # Implementing the actual algorithm by adding a method to the run! function for our model: -function PlantSimEngine.run!(m::ToyLAIfromLeafAreaModel, models, status, meteo, constants=nothing, extra=nothing) +function PlantSimEngine.run!(m::ToyLAIfromLeafAreaModel, status, environment, constants, context) status.total_surface = sum(status.plant_surfaces) 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..cf8e6a716 100644 --- a/examples/ToyLeafSurfaceModel.jl +++ b/examples/ToyLeafSurfaceModel.jl @@ -27,21 +27,19 @@ end # Define inputs: function PlantSimEngine.inputs_(::ToyLeafSurfaceModel) - (carbon_biomass=-Inf,) + (carbon_biomass=Required(Real),) end # Define outputs: -function PlantSimEngine.outputs_(::ToyLeafSurfaceModel) - (surface=-Inf,) +function PlantSimEngine.outputs_(model::ToyLeafSurfaceModel) + (surface=oftype(float(model.SLA), -Inf),) end -function PlantSimEngine.run!(m::ToyLeafSurfaceModel, models, status, meteo, constants, extra_args) +function PlantSimEngine.run!(m::ToyLeafSurfaceModel, status, environment, constants, context) status.surface = status.carbon_biomass * m.SLA end # Can be parallelized over organs and time-steps: -PlantSimEngine.ObjectDependencyTrait(::Type{<:ToyLeafSurfaceModel}) = PlantSimEngine.IsObjectIndependent() -PlantSimEngine.TimeStepDependencyTrait(::Type{<:ToyLeafSurfaceModel}) = PlantSimEngine.IsTimeStepDependent() @@ -64,7 +62,7 @@ struct ToyPlantLeafSurfaceModel <: AbstractLeaf_SurfaceModel end # Define inputs: function PlantSimEngine.inputs_(::ToyPlantLeafSurfaceModel) - (leaf_surfaces=[-Inf],) + (leaf_surfaces=Required(AbstractVector{<:Real}),) end # Define outputs: @@ -72,9 +70,6 @@ function PlantSimEngine.outputs_(::ToyPlantLeafSurfaceModel) (surface=-Inf,) end -function PlantSimEngine.run!(m::ToyPlantLeafSurfaceModel, models, status, meteo, constants, extra_args) +function PlantSimEngine.run!(m::ToyPlantLeafSurfaceModel, status, environment, constants, context) 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..77db042fa 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 @@ -24,13 +24,15 @@ Computes the light partitioning based on relative surface. struct ToyLightPartitioningModel <: AbstractLight_PartitioningModel end # Define inputs: -PlantSimEngine.inputs_(::ToyLightPartitioningModel) = (aPPFD_larger_scale=-Inf, total_surface=-Inf, surface=-Inf,) +PlantSimEngine.inputs_(::ToyLightPartitioningModel) = ( + aPPFD_larger_scale=Required(Real), + total_surface=Required(Real), + surface=Required(Real), +) # Define outputs: PlantSimEngine.outputs_(::ToyLightPartitioningModel) = (aPPFD=-Inf,) -function PlantSimEngine.run!(::ToyLightPartitioningModel, models, status, meteo, constants, extra) +function PlantSimEngine.run!(::ToyLightPartitioningModel, status, environment, constants, context) 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/ToyMaintenanceRespirationModel.jl b/examples/ToyMaintenanceRespirationModel.jl index 492e039e3..32c50806c 100644 --- a/examples/ToyMaintenanceRespirationModel.jl +++ b/examples/ToyMaintenanceRespirationModel.jl @@ -30,13 +30,41 @@ struct ToyMaintenanceRespirationModel{T} <: AbstractMaintenance_RespirationModel nitrogen_content::T end -PlantSimEngine.inputs_(::ToyMaintenanceRespirationModel) = (carbon_biomass=0.0,) -PlantSimEngine.outputs_(::ToyMaintenanceRespirationModel) = (Rm=-Inf,) +function ToyMaintenanceRespirationModel(Q10, Rm_base, T_ref, P_alive, nitrogen_content) + parameters = promote( + float(Q10), + float(Rm_base), + float(T_ref), + float(P_alive), + float(nitrogen_content), + ) + return ToyMaintenanceRespirationModel{typeof(first(parameters))}(parameters...) +end -function PlantSimEngine.run!(m::ToyMaintenanceRespirationModel, models, status, meteo, constants, extra=nothing) +PlantSimEngine.inputs_(::ToyMaintenanceRespirationModel) = ( + carbon_biomass=Required(Real), +) +PlantSimEngine.outputs_(model::ToyMaintenanceRespirationModel) = ( + Rm=oftype(model.Rm_base, -Inf), +) +PlantSimEngine.environment_inputs_( + model::ToyMaintenanceRespirationModel, +) = (T=zero(model.T_ref),) + +function PlantSimEngine.run!( + model::ToyMaintenanceRespirationModel, + status, + environment, + constants, + context, +) status.Rm = - status.carbon_biomass * m.P_alive * m.nitrogen_content * m.Rm_base * - m.Q10^((meteo.T - m.T_ref) / 10.0) + status.carbon_biomass * + model.P_alive * + model.nitrogen_content * + model.Rm_base * + model.Q10^((environment.T - model.T_ref) / 10) + return nothing end """ @@ -44,7 +72,7 @@ end Total plant maintenance respiration based on the sum of `Rm_organs`, the maintenance respiration of the organs. -# Intputs +# Inputs - `Rm_organs`: a vector of maintenance respiration from all organs in the plant in gC time-step⁻¹ @@ -54,9 +82,18 @@ Total plant maintenance respiration based on the sum of `Rm_organs`, the mainten """ struct ToyPlantRmModel <: AbstractMaintenance_RespirationModel end -PlantSimEngine.inputs_(::ToyPlantRmModel) = (Rm_organs=[-Inf],) +PlantSimEngine.inputs_(::ToyPlantRmModel) = ( + Rm_organs=Required(AbstractVector{<:Real}), +) PlantSimEngine.outputs_(::ToyPlantRmModel) = (Rm=-Inf,) -function PlantSimEngine.run!(::ToyPlantRmModel, models, status, meteo, constants, extra=nothing) +function PlantSimEngine.run!( + ::ToyPlantRmModel, + status, + environment, + constants, + context, +) status.Rm = sum(status.Rm_organs) -end \ No newline at end of file + return nothing +end diff --git a/examples/ToyModelDeveloper.jl b/examples/ToyModelDeveloper.jl new file mode 100644 index 000000000..ca32f9886 --- /dev/null +++ b/examples/ToyModelDeveloper.jl @@ -0,0 +1,82 @@ +PlantSimEngine.@process "toy_development" verbose = false +PlantSimEngine.@process "toy_daily_development" verbose = false + +""" + ToyDevelopmentModel(efficiency) + +Compute one growth increment from required thermal time and an optional stress +factor. + +# Inputs + +- `TT`: required thermal time for the current step. +- `stress`: dimensionless stress factor, defaulting to `1.0`. + +# Outputs + +- `growth`: growth increment for the current step. +""" +struct ToyDevelopmentModel{T} <: AbstractToy_DevelopmentModel + efficiency::T +end + +function ToyDevelopmentModel(efficiency::Real) + parameter = float(efficiency) + return ToyDevelopmentModel{typeof(parameter)}(parameter) +end + +PlantSimEngine.inputs_(::ToyDevelopmentModel) = ( + TT=Required(Real), + stress=Default(1.0), +) +PlantSimEngine.outputs_(model::ToyDevelopmentModel) = ( + growth=zero(model.efficiency), +) + +function PlantSimEngine.run!( + model::ToyDevelopmentModel, + status, + environment, + constants, + context, +) + status.growth = model.efficiency * status.TT * status.stress + return nothing +end + +""" + ToyDailyDevelopmentModel(increment) + +Accumulate one configured growth increment every 24 simulation steps. The model +declares this default cadence and hold-last output semantics. +""" +struct ToyDailyDevelopmentModel{T} <: + AbstractToy_Daily_DevelopmentModel + increment::T +end + +function ToyDailyDevelopmentModel(increment::Real) + parameter = float(increment) + return ToyDailyDevelopmentModel{typeof(parameter)}(parameter) +end + +PlantSimEngine.inputs_(::ToyDailyDevelopmentModel) = NamedTuple() +PlantSimEngine.outputs_(model::ToyDailyDevelopmentModel) = ( + daily_growth=zero(model.increment), +) +PlantSimEngine.timespec(::Type{<:ToyDailyDevelopmentModel}) = + ClockSpec(24.0, 1.0) +PlantSimEngine.output_policy(::Type{<:ToyDailyDevelopmentModel}) = ( + daily_growth=HoldLast(), +) + +function PlantSimEngine.run!( + model::ToyDailyDevelopmentModel, + status, + environment, + constants, + context, +) + status.daily_growth += model.increment + return nothing +end 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..a74470b66 100644 --- a/examples/ToyRUEGrowthModel.jl +++ b/examples/ToyRUEGrowthModel.jl @@ -30,24 +30,21 @@ end # Define inputs: function PlantSimEngine.inputs_(::ToyRUEGrowthModel) - (aPPFD=-Inf,) + (aPPFD=Required(Real),) end # Define outputs: -function PlantSimEngine.outputs_(::ToyRUEGrowthModel) - (biomass=0.0, biomass_increment=-Inf) +function PlantSimEngine.outputs_(model::ToyRUEGrowthModel) + (biomass=zero(model.efficiency), biomass_increment=oftype(float(model.efficiency), -Inf)) end # Tells Julia what is the type of elements: Base.eltype(x::ToyRUEGrowthModel{T}) where {T} = T # Implement the growth model: -function PlantSimEngine.run!(::ToyRUEGrowthModel, models, status, meteo, constants, extra) - status.biomass_increment = status.aPPFD * models.growth.efficiency +function PlantSimEngine.run!(model::ToyRUEGrowthModel, status, environment, constants, context) + status.biomass_increment = status.aPPFD * model.efficiency 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..b3b9b9845 100644 --- a/examples/ToySoilModel.jl +++ b/examples/ToySoilModel.jl @@ -16,23 +16,21 @@ the `values` range using `rand`. - `values`: a range of `soil_water_content` values to sample from. Can be a vector of values `[0.5,0.6]` or a range `0.1:0.1:1.0`. Default is `[0.5]`. """ -struct ToySoilWaterModel{T<:Union{AbstractRange{Float64},AbstractVector{Float64}}} <: AbstractSoil_WaterModel +struct ToySoilWaterModel{T<:Union{AbstractRange,AbstractVector}} <: AbstractSoil_WaterModel 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() -PlantSimEngine.outputs_(::ToySoilWaterModel) = (soil_water_content=-Inf,) +PlantSimEngine.outputs_(m::ToySoilWaterModel) = ( + soil_water_content=oftype(float(first(m.values)), -Inf), +) # Implementing the actual algorithm by adding a method to the run! function for our model: -function PlantSimEngine.run!(m::ToySoilWaterModel, models, status, meteo, constants=nothing, extra=nothing) +function PlantSimEngine.run!(m::ToySoilWaterModel, status, environment, constants, context) 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/ToySpatialEnvironment.jl b/examples/ToySpatialEnvironment.jl new file mode 100644 index 000000000..9cb7a4b5e --- /dev/null +++ b/examples/ToySpatialEnvironment.jl @@ -0,0 +1,197 @@ +""" + ToySpatialEnvironment(cells; step_seconds=3600.0) + +A minimal spatial environment for examples and tests. + +`cells` maps cell ids to named tuples of environment variables. Objects select +a cell with geometry such as `(cell=:sun,)`. PlantSimEngine compiles that cell +id into a [`ToyEnvironmentHandle`](@ref), so sampling does not resolve geometry +inside the model kernel loop. An application configured with `sink=:cells` may +also commit an accepted named-tuple state to its bound cell. +""" +struct ToySpatialEnvironment{C,T} <: + PlantSimEngine.EnvironmentAPI.AbstractEnvironmentBackend + cells::C + step_seconds::T +end + +ToySpatialEnvironment(cells; step_seconds=3600.0) = + ToySpatialEnvironment(cells, float(step_seconds)) + +""" + ToyEnvironmentHandle + +Opaque compiled handle returned by [`ToySpatialEnvironment`](@ref). +""" +struct ToyEnvironmentHandle + cell::Symbol + sink::Union{Nothing,Symbol} +end + +PlantSimEngine.EnvironmentAPI.base_step_seconds(backend::ToySpatialEnvironment) = + backend.step_seconds +PlantSimEngine.EnvironmentAPI.get_nsteps(::ToySpatialEnvironment) = 1 + +function PlantSimEngine.EnvironmentAPI.environment_variables( + backend::ToySpatialEnvironment, +) + isempty(backend.cells) && return Set{Symbol}() + return Set(Symbol.(propertynames(first(values(backend.cells))))) +end + +function PlantSimEngine.EnvironmentAPI.bind_environment( + backend::ToySpatialEnvironment, + object::PlantSimEngine.Object, + context::PlantSimEngine.EnvironmentAPI.EnvironmentContext, + config, +) + object_geometry = PlantSimEngine.geometry(object) + object_geometry isa NamedTuple && haskey(object_geometry, :cell) || error( + "ToySpatialEnvironment needs `(cell=...,)` geometry for object " * + "`$(object.id.value)`.", + ) + cell = Symbol(object_geometry.cell) + haskey(backend.cells, cell) || error( + "ToySpatialEnvironment has no cell `$(cell)` for object " * + "`$(object.id.value)`.", + ) + sink = + isnothing(config) || !haskey(config, :sink) ? + nothing : Symbol(config.sink) + isnothing(sink) || sink == :cells || error( + "ToySpatialEnvironment only supports `sink=:cells`, got " * + "`$(sink)`.", + ) + return ToyEnvironmentHandle(cell, sink) +end + +function PlantSimEngine.EnvironmentAPI.sample( + backend::ToySpatialEnvironment, + handle::ToyEnvironmentHandle, + variable::Symbol, + time, +) + row = backend.cells[handle.cell] + hasproperty(row, variable) || error( + "ToySpatialEnvironment cell `$(handle.cell)` does not provide " * + "variable `$(variable)`.", + ) + return getproperty(row, variable) +end + +function PlantSimEngine.EnvironmentAPI.sample( + backend::ToySpatialEnvironment, + handle::ToyEnvironmentHandle, + state::NamedTuple, + variable::Symbol, + time, +) + hasproperty(state, variable) || error( + "ToySpatialEnvironment trial state does not provide variable " * + "`$(variable)`.", + ) + return getproperty(state, variable) +end + +function PlantSimEngine.EnvironmentAPI.commit_environment!( + backend::ToySpatialEnvironment, + handle::ToyEnvironmentHandle, + state::NamedTuple, + time, +) + handle.sink == :cells || error( + "ToySpatialEnvironment handle for cell `$(handle.cell)` has no " * + "commit sink.", + ) + backend.cells[handle.cell] = state + return nothing +end + +PlantSimEngine.@process "toy_environment_reader" verbose = false +PlantSimEngine.@process "toy_environment_controller" verbose = false + +""" + ToyEnvironmentReaderModel() + +Read temperature from the model-facing environment. +""" +struct ToyEnvironmentReaderModel <: AbstractToy_Environment_ReaderModel end + +PlantSimEngine.inputs_(::ToyEnvironmentReaderModel) = NamedTuple() +PlantSimEngine.outputs_(::ToyEnvironmentReaderModel) = (temperature_seen=0.0,) +PlantSimEngine.environment_inputs_(::ToyEnvironmentReaderModel) = (T=0.0,) + +function PlantSimEngine.run!( + ::ToyEnvironmentReaderModel, + status, + environment, + constants, + context, +) + status.temperature_seen = environment.T + return nothing +end + +""" + ToyEnvironmentControllerModel(trial_temperature, accepted_temperature) + +Demonstrate a typed trial environment followed by one accepted environment +commit and publication. +""" +struct ToyEnvironmentControllerModel{T} <: + AbstractToy_Environment_ControllerModel + trial_temperature::T + accepted_temperature::T +end + +function ToyEnvironmentControllerModel(trial_temperature, accepted_temperature) + parameters = promote( + float(trial_temperature), + float(accepted_temperature), + ) + return ToyEnvironmentControllerModel(parameters...) +end + +PlantSimEngine.inputs_(::ToyEnvironmentControllerModel) = NamedTuple() +PlantSimEngine.dep(::ToyEnvironmentControllerModel) = ( + reader=Call(One(process=:toy_environment_reader)), +) +function PlantSimEngine.outputs_(model::ToyEnvironmentControllerModel) + initial = zero(model.accepted_temperature) + return ( + trial_temperature_seen=initial, + accepted_temperature_seen=initial, + ) +end +PlantSimEngine.environment_outputs_(model::ToyEnvironmentControllerModel) = ( + T=zero(model.accepted_temperature), +) + +function PlantSimEngine.run!( + model::ToyEnvironmentControllerModel, + status, + environment, + constants, + context, +) + trial_environment = (T=model.trial_temperature,) + trial_target = only(run_call!( + context, + :reader; + environment=trial_environment, + publish=false, + )) + status.trial_temperature_seen = trial_target.status.temperature_seen + + accepted_environment = (T=model.accepted_temperature,) + commit_environment!(context, accepted_environment) + accepted_target = only(run_call!( + context, + :reader; + environment=accepted_environment, + publish=true, + )) + status.accepted_temperature_seen = + accepted_target.status.temperature_seen + return nothing +end 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..eb84eac0d 100644 --- a/examples/dummy.jl +++ b/examples/dummy.jl @@ -13,13 +13,11 @@ A dummy model implementing a "process1" process for testing purposes. struct Process1Model <: AbstractProcess1Model a end -PlantSimEngine.inputs_(::Process1Model) = (var1=-Inf, var2=-Inf) +PlantSimEngine.inputs_(::Process1Model) = (var1=Required(Float64), var2=Required(Float64)) 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 +function PlantSimEngine.run!(model::Process1Model, status, environment, constants, context) + status.var3 = model.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 @@ -32,18 +30,19 @@ PlantSimEngine.@process "process2" verbose = false A dummy model implementing a "process2" process for testing purposes. """ struct Process2Model <: AbstractProcess2Model end -PlantSimEngine.inputs_(::Process2Model) = (var1=-Inf, var3=-Inf) +PlantSimEngine.inputs_(::Process2Model) = (var1=Required(Float64), var3=Required(Float64)) PlantSimEngine.outputs_(::Process2Model) = (var4=-Inf, var5=-Inf) -PlantSimEngine.dep(::Process2Model) = (process1=AbstractProcess1Model,) -function PlantSimEngine.run!(::Process2Model, models, status, meteo, constants=nothing, extra=nothing) +PlantSimEngine.environment_inputs_(::Process2Model) = (T=0.0, Wind=0.0, Rh=0.0) +PlantSimEngine.dep(::Process2Model) = ( + process1=PlantSimEngine.Call(PlantSimEngine.One(process=:process1)), +) +function PlantSimEngine.run!(::Process2Model, status, environment, constants, context) # computing var3 using process1: - PlantSimEngine.run!(models.process1, models, status, meteo, constants) + PlantSimEngine.run_call!(context, :process1; 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 + status.var5 = status.var4 + 1.0 * environment.T + 2.0 * environment.Wind + 3.0 * environment.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 @@ -56,20 +55,20 @@ PlantSimEngine.@process "process3" verbose = false A dummy model implementing a "process3" process for testing purposes. """ struct Process3Model <: AbstractProcess3Model end -PlantSimEngine.inputs_(::Process3Model) = (var5=-Inf,) +PlantSimEngine.inputs_(::Process3Model) = (var5=Required(Float64),) 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,) -function PlantSimEngine.run!(::Process3Model, models, status, meteo, constants=nothing, extra=nothing) - # computing var3 using process1: - PlantSimEngine.run!(models.process2, models, status, meteo, constants, extra) +PlantSimEngine.dep(::Process3Model) = ( + process2=PlantSimEngine.Call(PlantSimEngine.One(process=:process2)), +) +function PlantSimEngine.run!(::Process3Model, status, environment, constants, context) + # computing var3, var4 and var5 using process2 (which calls process1): + PlantSimEngine.run_call!(context, :process2; 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 @@ -83,16 +82,14 @@ A dummy model implementing a "process4" process for testing purposes. It computes the inputs needed for the coupled processes 1-2-3. """ struct Process4Model <: AbstractProcess4Model end -PlantSimEngine.inputs_(::Process4Model) = (var0=-Inf,) +PlantSimEngine.inputs_(::Process4Model) = (var0=Required(Float64),) PlantSimEngine.outputs_(::Process4Model) = (var1=-Inf, var2=-Inf) -function PlantSimEngine.run!(::Process4Model, models, status, meteo, constants=nothing, extra=nothing) +function PlantSimEngine.run!(::Process4Model, status, environment, constants, context) # computing var3 using process1: # re-computing var4: 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 @@ -106,13 +103,11 @@ A dummy model implementing a "process5" process for testing purposes. It needs the outputs from the coupled processes 1-2-3. """ struct Process5Model <: AbstractProcess5Model end -PlantSimEngine.inputs_(::Process5Model) = (var5=-Inf, var6=-Inf) +PlantSimEngine.inputs_(::Process5Model) = (var5=Required(Float64), var6=Required(Float64)) PlantSimEngine.outputs_(::Process5Model) = (var7=-Inf,) -function PlantSimEngine.run!(::Process5Model, models, status, meteo, constants=nothing, extra=nothing) +function PlantSimEngine.run!(::Process5Model, status, environment, constants, context) 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 @@ -128,13 +123,11 @@ It needs the outputs from the coupled processes 1-2-3, but also from process 7 that is itself independant. """ struct Process6Model <: AbstractProcess6Model end -PlantSimEngine.inputs_(::Process6Model) = (var7=-Inf, var9=-Inf) +PlantSimEngine.inputs_(::Process6Model) = (var7=Required(Float64), var9=Required(Float64)) PlantSimEngine.outputs_(::Process6Model) = (var8=-Inf,) -function PlantSimEngine.run!(::Process6Model, models, status, meteo, constants=nothing, extra=nothing) +function PlantSimEngine.run!(::Process6Model, status, environment, constants, context) 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. @@ -150,10 +143,8 @@ It is independent (needs :var0 only as for Process4Model), but its outputs are used by Process6Model, so it is a soft-coupling. """ struct Process7Model <: AbstractProcess7Model end -PlantSimEngine.inputs_(::Process7Model) = (var0=-Inf, var3=-Inf) +PlantSimEngine.inputs_(::Process7Model) = (var0=Required(Float64), var3=Required(Float64)) PlantSimEngine.outputs_(::Process7Model) = (var9=-Inf,) -function PlantSimEngine.run!(::Process7Model, models, status, meteo, constants=nothing, extra=nothing) +function PlantSimEngine.run!(::Process7Model, status, environment, constants, context) 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..609186182 --- /dev/null +++ b/examples/maespa_model_example.jl @@ -0,0 +1,789 @@ +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 + +duration_seconds(environment) = Dates.value(Dates.Millisecond(environment.duration)) / 1000.0 +mutable struct MaespaSingleLayerEnvironment{F,C} <: + PlantSimEngine.EnvironmentAPI.AbstractEnvironmentBackend + forcing::F # MAESPA forcing data (Meteo data from above the canopy) + canopy::C # Within-canopy computed microclimate +end + +struct MaespaEnvironmentHandle + provider::Symbol + sink::Union{Nothing,Symbol} +end + +function MaespaSingleLayerEnvironment(forcing; canopy=_maespa_meteo_row(forcing, 1)) + canopy = Atmosphere( + T=canopy.T, + Rh=canopy.Rh, + Wind=canopy.Wind, + P=canopy.P, + Cₐ=canopy.Cₐ, + Ri_PAR_f=canopy.Ri_PAR_f, + Ri_SW_f=canopy.Ri_SW_f, + duration=canopy.duration, + ) + return MaespaSingleLayerEnvironment( + forcing, + canopy, + ) +end + +_maespa_meteo_row(environment, time) = + first(Iterators.drop(environment, clamp(Int(round(time)), 1, PlantSimEngine.get_nsteps(environment)) - 1)) + +PlantSimEngine.EnvironmentAPI.base_step_seconds( + backend::MaespaSingleLayerEnvironment, +) = PlantSimEngine.EnvironmentAPI.base_step_seconds( + PlantSimEngine.EnvironmentAPI.environment_backend(backend.forcing), +) +PlantSimEngine.EnvironmentAPI.get_nsteps( + backend::MaespaSingleLayerEnvironment, +) = PlantSimEngine.EnvironmentAPI.get_nsteps(backend.forcing) +PlantSimEngine.EnvironmentAPI.environment_variables( + ::MaespaSingleLayerEnvironment, +) = Set([ + :T, :Rh, :Wind, :P, :Cₐ, :Ri_PAR_f, :Ri_SW_f, :duration, :VPD, :ε, :γ, :Δ, :ρ, :λ, +]) + +function PlantSimEngine.EnvironmentAPI.bind_environment( + backend::MaespaSingleLayerEnvironment, + object::Object, + context::PlantSimEngine.EnvironmentAPI.EnvironmentContext, + config, +) + provider = isnothing(config) ? :canopy : Symbol(config.provider) + sink = isnothing(config) || !haskey(config, :sink) ? nothing : Symbol(config.sink) + provider in (:forcing, :canopy) || error( + "MAESPA single-layer environment provider must be `:forcing` or `:canopy`, got `$(provider)`." + ) + isnothing(sink) || sink == :canopy || error( + "MAESPA single-layer environment sink must be `:canopy`, got `$(sink)`." + ) + return MaespaEnvironmentHandle(provider, sink) +end + +function PlantSimEngine.EnvironmentAPI.sample( + backend::MaespaSingleLayerEnvironment, + handle::MaespaEnvironmentHandle, + variable::Symbol, + time, +) + environment = handle.provider == :forcing ? + _maespa_meteo_row(backend.forcing, time) : + backend.canopy + return getproperty(environment, variable) +end + +function PlantSimEngine.EnvironmentAPI.sample( + backend::MaespaSingleLayerEnvironment{F,C}, + handle::MaespaEnvironmentHandle, + state::C, + variable::Symbol, + time, +) where {F,C} + environment = handle.provider == :forcing ? + _maespa_meteo_row(backend.forcing, time) : + state + return getproperty(environment, variable) +end + +function PlantSimEngine.EnvironmentAPI.commit_environment!( + backend::MaespaSingleLayerEnvironment{F,C}, + handle::MaespaEnvironmentHandle, + state::C, + time, +) where {F,C} + handle.sink == :canopy || error( + "MAESPA environment handle for provider `$(handle.provider)` has no `:canopy` commit sink." + ) + backend.canopy = state + return nothing +end + +struct SoilWater{T} <: AbstractSoil_WaterModel + theta_sat::T + psi_e::T + b::T + depth1::T + depth2::T +end + +PlantSimEngine.inputs_(::SoilWater) = (transpiration=Required(Float64), infiltration=Required(Float64)) +PlantSimEngine.outputs_(::SoilWater) = (theta1=0.32, theta2=0.34, psi_soil=-0.1) + +function PlantSimEngine.run!(m::SoilWater, status, environment, constants, context) + 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, status, environment, constants, context) = 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=Required(Vector{Float64}),) +PlantSimEngine.outputs_(::LAIModel) = (lai=0.0, leaf_area=(-Inf)) + +function PlantSimEngine.run!(m::LAIModel, status, environment, constants, context) + 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=Required(Float64), + leaf_area=Required(Float64), + leaf_areas=Required(Vector{Float64}), + leaf_carbon=Required(Vector{Float64}), + leaf_Ra_SW_f=Required(Vector{Float64}), + leaf_aPPFD=Required(Vector{Float64}), + Ψₗ=Required(Vector{Float64}), + leaf_rn=Required(Vector{Float64}), + leaf_lambda_e=Required(Vector{Float64}), + leaf_h=Required(Vector{Float64}), + leaf_a=Required(Vector{Float64}), + psi_soil=Required(Float64), +) +PlantSimEngine.environment_inputs_(::SceneEB) = ( + T=0.0, + Rh=0.0, + Wind=0.0, + P=0.0, + Cₐ=0.0, + Ri_PAR_f=0.0, + Ri_SW_f=0.0, + duration=Dates.Hour(1), + VPD=0.0, + λ=0.0, +) +PlantSimEngine.environment_outputs_(::SceneEB) = (T=0.0, Rh=0.0) +PlantSimEngine.outputs_(::SceneEB) = ( + canopy_rn=0.0, + canopy_lambda_e=0.0, + canopy_h=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, + 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(environment, tair_canopy, vpd_canopy) + return Atmosphere( + T=tair_canopy, + Rh=rh_from_vpd(vpd_canopy, e_sat(tair_canopy)), + Wind=environment.Wind, + P=environment.P, + Cₐ=environment.Cₐ, + Ri_PAR_f=environment.Ri_PAR_f, + Ri_SW_f=environment.Ri_SW_f, + duration=environment.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 + fluxes = ( + rn=total_rn / ground_area, + lambda_e=total_lambda_e / ground_area, + h=total_h / ground_area, + a=total_a / ground_area, + environment=local_meteo, + ) + status.canopy_rn = fluxes.rn + status.canopy_lambda_e = fluxes.lambda_e + status.canopy_h = fluxes.h + status.scene_assimilation = fluxes.a + return fluxes +end + +function _prepare_model_leaf_inputs!(status, environment, psi_soil) + # Prepare the leaf status for each leaf target, and run the energy balance for each leaf: + status.leaf_Ra_SW_f .= environment.Ri_SW_f + status.leaf_aPPFD .= environment.Ri_PAR_f + status.Ψₗ .= psi_soil + return nothing +end + +function _run_model_leaf_targets!(context, status, local_meteo, meteo_above, psi_soil, ground_area; publish=false) + _prepare_model_leaf_inputs!(status, meteo_above, psi_soil) + run_call!( + context, + :energy_balance; + environment=local_meteo, + publish=publish, + ) + fluxes = _aggregate_model_leaf_fluxes(status, ground_area, local_meteo) + return fluxes +end + +function _run_model_leaf_targets_from_environment!(context, status, local_meteo, meteo_above, psi_soil, ground_area; publish=false) + _prepare_model_leaf_inputs!(status, meteo_above, psi_soil) + run_call!(context, :energy_balance; publish=publish) + fluxes = _aggregate_model_leaf_fluxes(status, ground_area, local_meteo) + return fluxes +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 canopy_air_update(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 = PlantMeteo.e_sat(tair_above) - vpd_above + vpair_canopy = vpair_above + etot * canopy_meteo.γ / heat_conductance + vpd_new = max(0.01, PlantMeteo.e_sat(tair_new) - vpair_canopy) + vpd_new = clamp(vpd_new, max(0.01, vpd_above - 1.5), vpd_above + 1.5) + environment = _model_leaf_meteo(meteo_above, tair_new, vpd_new) + return (environment=environment, tair=tair_new, vpd=vpd_new, rh=environment.Rh, htot=htot, gcanop=gbcan_ms) +end + +function _solve_model_energy_balance!( + m::SceneEB, + context, + status, + environment, + constants=PlantMeteo.Constants(), +) + tair_above = environment.T + vpd_above = max(0.01, environment.VPD) + tair_canopy = tair_above + vpd_canopy = vpd_above + psi_soil = status.psi_soil + final_meteo = environment + last_update = (tair=tair_canopy, vpd=vpd_canopy, rh=environment.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: + trial_meteo = _model_leaf_meteo(environment, tair_canopy, vpd_canopy) + fluxes = _run_model_leaf_targets!(context, status, trial_meteo, environment, psi_soil, m.ground_area) + # Update the canopy-scale environment based on the leaf fluxes, and check for convergence: + final_meteo = fluxes.environment + update = canopy_air_update(m, fluxes, environment, trial_meteo, constants) + status.canopy_tair = update.tair + status.canopy_vpd = update.vpd + status.canopy_rh = update.rh + status.canopy_htot = update.htot + status.canopy_gcanop = update.gcanop + 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, + update.environment, + 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!(context, status, solution::SceneEBSolverResult, environment, ground_area) + commit_environment!(context, solution.final_meteo) + fluxes = _run_model_leaf_targets_from_environment!( + context, + status, + solution.final_meteo, + environment, + 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(environment) * 12.0e-6 + end + return fluxes +end + +function PlantSimEngine.run!(m::SceneEB, status, environment, constants, context) + solution = _solve_model_energy_balance!(m, context, status, environment, constants) + fluxes = _publish_model_leaf_solution!(context, status, solution, environment, m.ground_area) + transpiration_mm = λE_to_E(fluxes.lambda_e, solution.final_meteo.λ) * duration_seconds(environment) * 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!(context, :soil; publish=true) + return nothing +end + +alloc_inputs() = (leaf_carbon=Required(Vector{Float64}),) +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, status, environment, constants, context) = + allocate!(status, m.leaf_fraction, m.wood_fraction) +PlantSimEngine.run!(m::AllocB, status, environment, constants, context) = + 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], + canopy_rn=0.0, + canopy_lambda_e=0.0, + canopy_h=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, on=Many(scale=:Leaf), calls=(:photosynthesis => One(scale=:Leaf, application=:photosynthesis)), environment=Environment(provider=:canopy), every=Dates.Hour(1)), + ModelSpec(fvcb; name=:photosynthesis, on=Many(scale=:Leaf), calls=(:stomatal_conductance => One(scale=:Leaf, application=:stomatal_conductance)), every=Dates.Hour(1)), + ModelSpec(tuzet; name=:stomatal_conductance, on=Many(scale=:Leaf), every=Dates.Hour(1)), + ModelSpec(LeafState(); name=:leaf_state, on=Many(scale=:Leaf), every=Dates.Hour(1)), + ModelSpec(allocation; name=:allocation, on=One(scale=:Plant), inputs=(:leaf_carbon => Many(scale=:Leaf, within=Subtree(), var=:leaf_carbon)), every=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), environment=maespa_meteo()) + environment = environment isa MaespaSingleLayerEnvironment ? environment : MaespaSingleLayerEnvironment(environment) + 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, on=One(scale=:Scene), inputs=(:leaf_areas => Many( + kind=:plant, + scale=:Leaf, + within=SceneScope(), + process=:leaf_state, + var=:leaf_area, + ),), every=Dates.Day(1)), + ModelSpec(scene_model; name=:scene_eb, on=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),), environment=Environment(provider=:forcing, sink=:canopy), every=Dates.Hour(1)), + ModelSpec(SoilWater(0.45, -0.03, 4.4, 0.25, 0.75); name=:soil_water, on=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, + ),), every=Dates.Hour(1)), + ), + environment=environment, + ) +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(; environment=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..d983bad38 --- /dev/null +++ b/examples/plantbiophysics_subsample/FvCB.jl @@ -0,0 +1,196 @@ +# Generate all methods for the photosynthesis process: several environment 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 `ModelSpec(...; 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=Required(Float64), + Tₗ=Required(Float64), + Cₛ=Required(Float64), + ) +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, status, environment, constants, context) + + # 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): + stomatal_target = + only(PlantSimEngine.call_targets(context, :stomatal_conductance)) + stomatal_model = PlantSimEngine.runtime_model(stomatal_target) + st_closure = + gs_closure(stomatal_model, status, environment, constants, context) + + Cᵢⱼ = get_Cᵢⱼ(Vⱼ, Γˢ, status.Cₛ, Rd, stomatal_model.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, stomatal_model.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!( + only(PlantSimEngine.call_targets(context, :stomatal_conductance)); + sampled_environment=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..3919d871d --- /dev/null +++ b/examples/plantbiophysics_subsample/Monteith.jl @@ -0,0 +1,730 @@ +#! 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=Required(Float64), + sky_fraction=Required(Float64), + d=Required(Float64), + ) +end + +function PlantSimEngine.environment_inputs_(::Monteith) + ( + T=0.0, + Rh=0.0, + Wind=0.0, + P=0.0, + Cₐ=0.0, + ε=0.0, + VPD=0.0, + γ=0.0, + Δ=0.0, + ρ=0.0, + ) +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!(model::Monteith, status, environment, constants, context) + +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 + +- `model`: the current Monteith model instance. +- `status`: the application-local view of the target `Object` state, with + initial values 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). +- `environment`: sampled environment, see [`Atmosphere`](https://palmstudio.github.io/PlantMeteo.jl/stable/#PlantMeteo.Atmosphere). +- `constants`: physical constants supplied by the `CompositeModel` run. +- `context`: compiled runtime context used for the declared photosynthesis + hard call. + +# 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!(model::Monteith, status, environment, constants, context) + + # Initialisations + status.Tₗ = environment.T - 0.2 + Tₗ_new = zero(environment.T) + status.Cₛ = environment.Cₐ + status.Dₗ = PlantMeteo.e_sat(status.Tₗ) - PlantMeteo.e_sat(environment.T) * environment.Rh + γˢ = Rbₕ = Δ = zero(environment.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:model.maxiter + + # Update A, Gₛ, Cᵢ through the declared photosynthesis call: + PlantSimEngine.run_call!( + only(PlantSimEngine.call_targets(context, :photosynthesis)); + sampled_environment=environment, + publish=false, + ) + + # Stomatal resistance to water vapor + Rsᵥ = 1.0 / (gsc_to_gsw(mol_to_ms(status.Gₛ, environment.T, environment.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ₗ, environment.T, model.ε, environment.ε, + 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(environment.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(environment.T, status.Tₗ, status.d, constants.Dₕ₀) + + gbₕ_forced(environment.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ₕ, environment.T, environment.P, constants.R, constants.K₀) / + constants.Gbc_to_Gbₕ + + # Update Cₛ using boundary layer conductance to CO₂ and assimilation: + status.Cₛ = min(environment.Cₐ, environment.Cₐ - status.A / (status.Gbc * model.aₛᵥ)) + + # Apparent value of psychrometer constant (kPa K−1) + γˢ = γ_star(environment.γ, model.aₛₕ, model.aₛᵥ, Rbᵥ, Rsᵥ, Rbₕ) + + status.λE = latent_heat(status.Rn, environment.VPD, γˢ, Rbₕ, environment.Δ, environment.ρ, + model.aₛₕ, constants.Cₚ) + + # If potential evaporation is needed, here is how to compute it: + # γˢₑ = γ_star(environment.γ, energy_balance.aₛₕ, 1, Rbᵥ, 1.0e-9, Rbₕ) # Rsᵥ is inf. low + # Ev = latent_heat(status.Rn, environment.VPD, γˢₑ, Rbₕ, environment.Δ, environment.ρ, energy_balance.aₛₕ, constants.Cₚ) + + Tₗ_new = environment.T + (status.Rn - status.λE) / + (environment.ρ * constants.Cₚ * (model.aₛₕ / Rbₕ)) + + if abs(Tₗ_new - status.Tₗ) <= model.Δ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(environment.T) * environment.Rh + + iter += 1 + end + + status.H = sensible_heat(status.Rn, environment.VPD, γˢ, Rbₕ, environment.Δ, environment.ρ, + model.aₛₕ, constants.Cₚ) + + status.iter = iter + + @debug begin + if iter == model.maxiter + "`run!` algorithm did not converge. Please check the value." + end + end + + # Transpiration (mol[H₂O] m-2 s-1): + # ET = status.λE / environment.λ * 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..ff6920d3d --- /dev/null +++ b/examples/plantbiophysics_subsample/Tuzet.jl @@ -0,0 +1,107 @@ +#! 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 environment 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 accepts either an ordinary sampled environment or a closure value passed +# explicitly by the parent photosynthesis call. +function PlantSimEngine.run!(Gs::Gsm, status, environment, constants, context) where {Gsm<:AbstractStomatal_ConductanceModel} + closure = environment isa Number ? + environment : + gs_closure(Gs, status, environment, constants, context) + status.Gₛ = max( + Gs.gs_min, + Gs.g0 + closure * 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) + (Ψₗ=Required(Float64), Cₛ=Required(Float64)) +end + +function PlantSimEngine.outputs_(::Tuzet) + (Gₛ=-Inf,) +end + +Base.eltype(::Tuzet{T}) where T = T + +""" + gs_closure(::Tuzet, status, environment, constants=nothing, context=nothing) + +Stomatal closure for CO₂ according to Tuzet et al. (2003). + +# Arguments + +- `::Tuzet`: an instance of the `Tuzet` model type. +- `status`: A status struct holding the variables for the models. +- `environment`: sampled environment. It is not used in this model. +- `constants`: A constants struct holding the constants for the models. Is not used in this model. +- `context`: The runtime context. It 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, status, environment, constants=nothing, context=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..f40c80b40 --- /dev/null +++ b/ext/PlantSimEngineGraphEditorExt.jl @@ -0,0 +1,997 @@ +module PlantSimEngineGraphEditorExt + +import HTTP +import JSON +import PlantSimEngine +import PlantSimEngine.GraphEditor: edit_graph, current_model, apply_edit!, undo!, redo! + +mutable struct GraphEditorSession <: PlantSimEngine.GraphEditor.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: GraphEditor.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.GraphEditor.AbstractModelGraphEdit) + candidate = PlantSimEngine.GraphEditor.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.GraphEditor.model_graph_view(session.model) + return _write_response( + stream, + 200, + "text/html; charset=utf-8", + PlantSimEngine.GraphEditor.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.GraphEditor.apply_model_graph_edit( + session.model, + PlantSimEngine.GraphEditor.SetModelInputBinding( + application_id, + input, + _selector_from_payload(command["selector"]), + ), + ) + report = PlantSimEngine.GraphEditor.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.GraphEditor.RemoveModelApplication(application_id) + kind == "remove_template_application" && return PlantSimEngine.GraphEditor.RemoveModelTemplateApplication( + command["instance"], + application_id, + ) + kind == "mark_previous_timestep" && return PlantSimEngine.GraphEditor.MarkModelPreviousTimeStep( + application_id, + Symbol(command["input"]), + ) + kind == "unmark_previous_timestep" && return PlantSimEngine.GraphEditor.UnmarkModelPreviousTimeStep( + application_id, + Symbol(command["input"]), + ) + kind == "break_cycle" && return PlantSimEngine.GraphEditor.BreakModelCycle( + application_id, + Symbol(command["input"]), + Bool(get(command, "initializeMissing", false)), + _parameter_value(session, get(command, "initialValue", nothing)), + ) + kind == "set_application_targets" && return PlantSimEngine.GraphEditor.SetModelApplicationTargets( + application_id, + _selector_from_payload(command["selector"]), + ) + kind == "set_input_binding" && return PlantSimEngine.GraphEditor.SetModelInputBinding( + application_id, + Symbol(command["input"]), + _selector_from_payload(command["selector"]), + ) + kind == "remove_input_binding" && return PlantSimEngine.GraphEditor.RemoveModelInputBinding( + application_id, + Symbol(command["input"]), + ) + kind == "set_call_binding" && return PlantSimEngine.GraphEditor.SetModelCallBinding( + application_id, + Symbol(command["call"]), + _selector_from_payload(command["selector"]), + ) + kind == "remove_call_binding" && return PlantSimEngine.GraphEditor.RemoveModelCallBinding( + application_id, + Symbol(command["call"]), + ) + kind == "set_timestep" && return PlantSimEngine.GraphEditor.SetModelApplicationTimeStep( + application_id, + _timestep_from_payload(get(command, "timestep", nothing)), + ) + kind == "set_application_environment" && return PlantSimEngine.GraphEditor.SetModelApplicationEnvironment( + application_id, + _configuration_from_payload(session, get(command, "configuration", nothing)), + ) + kind == "set_environment_provider" && return PlantSimEngine.GraphEditor.SetModelApplicationEnvironment( + application_id, + _environment_with_provider(session.model, application_id, command["provider"]), + ) + kind == "set_output_routing" && return PlantSimEngine.GraphEditor.SetModelOutputRouting( + application_id, + Symbol(command["output"]), + Symbol(command["route"]), + ) + kind == "set_update_ordering" && return PlantSimEngine.GraphEditor.SetModelUpdateOrdering( + application_id, + _updates_from_payload(get(command, "updates", Any[])), + ) + kind == "set_object_status" && return PlantSimEngine.GraphEditor.SetModelObjectStatus( + command["objectId"], + Symbol(command["variable"]), + _parameter_value(session, command["value"]), + ) + kind == "set_object_statuses" && return PlantSimEngine.GraphEditor.SetModelObjectStatuses( + command["objectIds"], + Symbol(command["variable"]), + _parameter_value(session, command["value"]), + ) + kind == "remove_object_status" && return PlantSimEngine.GraphEditor.RemoveModelObjectStatus( + command["objectId"], + Symbol(command["variable"]), + ) + kind in ("set_object_metadata", "update_object") && return PlantSimEngine.GraphEditor.SetModelObjectMetadata( + PlantSimEngine.ObjectId(command["objectId"]), + _metadata_from_payload(get(command, "configuration", Dict())), + ) + kind == "add_object" && return PlantSimEngine.GraphEditor.AddModelObject( + _object_from_command(session, command), + ) + kind == "remove_object" && return PlantSimEngine.GraphEditor.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.GraphEditor.SetModelInstanceOverride( + command["instance"], + application_id, + _construct_model(session, command["modelType"], get(command, "parameters", Dict())), + ) + kind == "remove_instance_override" && return PlantSimEngine.GraphEditor.RemoveModelInstanceOverride( + command["instance"], + application_id, + ) + kind == "set_object_override" && return PlantSimEngine.GraphEditor.SetModelObjectOverride( + command["instance"], + command["objectId"], + application_id, + _construct_model(session, command["modelType"], get(command, "parameters", Dict())), + ) + kind == "remove_object_override" && return PlantSimEngine.GraphEditor.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.GraphEditor.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.GraphEditor.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.GraphEditor.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, + on=selector, + every=timestep,) + return PlantSimEngine.GraphEditor.AddModelApplication(spec) +end + +function _resolve_model_type(label) + text = String(label) + for model_type in PlantSimEngine.GraphEditor.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.GraphEditor.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 == "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.GraphEditor.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.GraphEditor.model_graph_view(session.model) + html = PlantSimEngine.GraphEditor.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.application) || push!(options, "application=$(repr(override.application))") + push!(options, "model=$(repr(override.model))") + return "Override(; $(join(options, ", ")))" +end + +function _application_code(spec) + options = String["name=$(repr(PlantSimEngine.application_name(spec)))"] + selector = PlantSimEngine.applies_to(spec) + isnothing(selector) || push!(options, "on=$(repr(selector))") + isempty(keys(PlantSimEngine.value_inputs(spec))) || + push!(options, "inputs=$(repr(PlantSimEngine.value_inputs(spec)))") + isempty(keys(PlantSimEngine.model_calls(spec))) || + push!(options, "calls=$(repr(PlantSimEngine.model_calls(spec)))") + environment = PlantSimEngine.environment_config(spec) + if !isnothing(environment) + payload = environment isa PlantSimEngine.EnvironmentConfig ? environment.config : environment + push!(options, "environment=Environment($(repr(payload)))") + end + isnothing(spec.timestep) || push!(options, "every=$(repr(spec.timestep))") + if !isempty(keys(PlantSimEngine.environment_bindings(spec))) + push!( + options, + "environment_bindings=$(repr(PlantSimEngine.environment_bindings(spec)))", + ) + end + if !isnothing(PlantSimEngine.environment_window(spec)) + push!( + options, + "environment_window=$(repr(PlantSimEngine.environment_window(spec)))", + ) + end + isempty(keys(PlantSimEngine.output_routing(spec))) || + push!(options, "output_routing=$(repr(PlantSimEngine.output_routing(spec)))") + update_codes = String[] + for update in PlantSimEngine.updates(spec) + variables = join(repr.(collect(update.variables)), ", ") + push!(update_codes, "Updates($(variables); after=$(repr(update.after)))") + end + if length(update_codes) == 1 + push!(options, "updates=$(only(update_codes))") + elseif !isempty(update_codes) + push!(options, "updates=($(join(update_codes, ", ")),)") + end + return "ModelSpec($(repr(PlantSimEngine.model_(spec))); $(join(options, ", ")))" +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..1ff7808eb 100644 --- a/skills/plantsimengine/SKILL.md +++ b/skills/plantsimengine/SKILL.md @@ -1,132 +1,232 @@ --- 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 and direct ModelSpec keywords, and to implement or wrap generic model kernels with inputs_, outputs_, dep, environment 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`, `Updates`, and `Environment`. +- **Modelers** implement or wrap generic kernels. They need process identity, + `inputs_`, `outputs_`, `dep`, `environment_inputs_`, `environment_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. + +Represent every runtime entity as an `Object` with stable identity and useful +labels. Plant topology remains scenario-defined. -out = run!(mapping, meteo) +```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 `on`. ```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, on=Many(scale=:Leaf)), + + ModelSpec(SoilModel(); name=:soil_model, on=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. - -### Variable mapping forms +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. -Use `MultiScaleModel(model, mapped_variables)` or pipe through `ModelSpec(model) |> MultiScaleModel(mapped_variables)`. +### Couple values with `inputs` -Common forms: +Declare each consumer's value source with the `inputs` keyword. ```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, + on=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, + on=One(scale=:Scene), + calls=( + :leaf_energy => Many( + scale=:Leaf, + within=SceneScope(), + process=:energy_balance, + ), + ), +) ``` -Policies: +Inside `run!`, execute all targets with `run_call!(context, :name)`, which always +returns a vector-like `CallTargets` collection. For selective or iterative +control, retrieve the collection with `call_targets(context, :name)` and execute +individual targets. `run_call!` defaults to `publish=false` for trial +iterations. Use `publish=true` once for the accepted state. -- `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. +### Configure rates and environment -Precedence: +Use `Dates.Period` values directly: -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 +ModelSpec( + DailyPlantModel(); + name=:daily_plant, + on=Many(scale=:Plant), + inputs=( + :leaf_fluxes => Many( + scale=:Leaf, + within=Subtree(), + var=:flux, + policy=Integrate(), + window=Dates.Day(1), + ), + ), + every=Dates.Day(1), +) +``` + +Policies are `HoldLast`, `Interpolate`, `Integrate`, and `Aggregate`. When an +`ModelSpec(...; 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 `environment_inputs_` and `environment_outputs_`. +`environment_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 `environment_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 `every` value is provided, the model scheduler honors +`timespec(::Type{<:Model})`; an explicit `every` 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 + +```julia +explain_applications(model) +explain_bindings(model) +explain_calls(model) +explain_schedule(model) +explain_writers(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 @@ -152,8 +252,8 @@ end PlantSimEngine.inputs_(::MyModel) = (x=0.0, y=-Inf) PlantSimEngine.outputs_(::MyModel) = (z=-Inf,) -function PlantSimEngine.run!(m::MyModel, models, status, meteo, constants, extra=nothing) - status.z = f(status.x, status.y, meteo.T, m.p) +function PlantSimEngine.run!(m::MyModel, status, environment, constants, context=nothing) + status.z = f(status.x, status.y, environment.T, m.p) return nothing end ``` @@ -163,8 +263,10 @@ Rules: - `inputs_` and `outputs_` are authoritative. Defaults are also initialization hints. - 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. +- Read sampled forcing through `environment` and physical constants through `constants`. +- In model runs, `context` is a `RunContext`. Use its public hard-call and + lifecycle APIs rather than attaching unrelated user data. Obtain the live + model with `runtime_model(context)`; do not inspect `context.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 +282,35 @@ 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 `ModelSpec(...; 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) +function PlantSimEngine.run!(m::ParentModel, status, environment, constants, context=nothing) + child = only(run_call!(context, :child; environment=environment, 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(); on=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}) = ( @@ -223,38 +320,64 @@ PlantSimEngine.output_policy(::Type{<:MyFluxModel}) = ( PlantSimEngine.timestep_hint(::Type{<:MyModel}) = (; required=(Dates.Hour(1), Dates.Hour(6)), preferred=Dates.Hour(1)) -PlantSimEngine.meteo_hint(::Type{<:MyModel}) = ( +PlantSimEngine.environment_hint(::Type{<:MyModel}) = ( bindings=(T=MeanReducer(),), window=RollingWindow(), ) ``` -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 -For user mappings: +- `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. + +## 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 `ModelSpec(...; inputs=...)` when the model expects scalar refs, `RefVector` inputs, or + renamed variables. +- Test multirate behavior when `every`, 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..5a4dc2024 --- /dev/null +++ b/src/ModelSpec.jl @@ -0,0 +1,365 @@ +""" + ModelSpec(model; name=nothing, on=nothing, inputs=NamedTuple(), + calls=NamedTuple(), environment=nothing, every=nothing, + environment_bindings=NamedTuple(), environment_window=nothing, + output_routing=NamedTuple(), updates=()) + +Configuration for one model application in a `CompositeModel`. + +`ModelSpec` is the single scenario-construction form. `on` selects the target +objects, `inputs` and `calls` declare coupling, `every` selects application +cadence, and `environment` accepts an [`Environment`](@ref) configuration. +Output routing and intentional duplicate-writer ordering are declared directly +with `output_routing` and `updates`. + +# Example + +```julia +ModelSpec( + model; + name=:leaf_energy, + on=Many(scale=:Leaf), + inputs=(:soil_water => One(scale=:Soil, var=:water),), + calls=(:stomata => One(within=Self(), application=:stomata),), + every=Dates.Hour(1), + environment=Environment(provider=:canopy), + output_routing=(temperature=:stream_only,), + updates=Updates(:temperature; after=:radiation), +) +``` +""" +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 + environment_bindings::MB + environment_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, + on=nothing, + inputs=NamedTuple(), + calls=NamedTuple(), + environment=nothing, + every=nothing, + environment_bindings=NamedTuple(), + environment_window=nothing, + output_routing=NamedTuple(), + updates=(), +) + return _build_model_spec( + model; + name=name, + on=on, + inputs=inputs, + calls=calls, + environment=environment, + every=every, + environment_bindings=environment_bindings, + environment_window=environment_window, + output_routing=output_routing, + updates=updates, + ) +end + +function _build_model_spec( + base_model::AbstractModel; + name=nothing, + on=nothing, + inputs=NamedTuple(), + input_origins=nothing, + calls=NamedTuple(), + call_origins=nothing, + environment=nothing, + every=nothing, + environment_bindings=NamedTuple(), + environment_window=nothing, + output_routing=NamedTuple(), + updates=(), +) + normalized_name = _normalize_application_name(name) + normalized_on = isnothing(on) ? nothing : + _validate_selector_context(on, :application_target) + default_inputs = _model_default_value_inputs(base_model) + explicit_inputs = _normalize_application_bindings(inputs) + normalized_inputs = _merge_value_inputs(default_inputs, explicit_inputs) + for selector in values(normalized_inputs) + _validate_selector_context(selector, :input) + end + normalized_input_origins = isnothing(input_origins) ? + _binding_origins(default_inputs, explicit_inputs) : + _normalize_binding_origins(input_origins, normalized_inputs) + _validate_scenario_application_references!( + normalized_inputs, + normalized_input_origins, + :inputs, + ) + default_calls = _model_default_model_calls(base_model) + explicit_calls = _normalize_application_bindings(calls) + normalized_calls = _merge_value_inputs(default_calls, explicit_calls) + for selector in values(normalized_calls) + _validate_selector_context(selector, :call) + end + normalized_call_origins = isnothing(call_origins) ? + _binding_origins(default_calls, explicit_calls) : + _normalize_binding_origins(call_origins, normalized_calls) + _validate_scenario_application_references!( + normalized_calls, + normalized_call_origins, + :calls, + ) + normalized_environment = _normalize_model_environment(environment) + normalized_environment_bindings = _normalize_environment_bindings(environment_bindings) + normalized_environment_window = _normalize_environment_window(environment_window) + normalized_output_routing = _normalize_output_routing(output_routing) + normalized_updates = _normalize_updates(updates) + return ModelSpec{typeof(base_model),typeof(normalized_name),typeof(normalized_on),typeof(normalized_inputs),typeof(normalized_input_origins),typeof(normalized_calls),typeof(normalized_call_origins),typeof(normalized_environment),typeof(every),typeof(normalized_environment_bindings),typeof(normalized_environment_window),typeof(normalized_output_routing),typeof(normalized_updates)}( + base_model, + normalized_name, + normalized_on, + normalized_inputs, + normalized_input_origins, + normalized_calls, + normalized_call_origins, + normalized_environment, + every, + normalized_environment_bindings, + normalized_environment_window, + normalized_output_routing, + normalized_updates + ) +end + +function _validate_scenario_application_references!( + bindings::NamedTuple, + origins::NamedTuple, + field::Symbol, +) + for (name, selector) in pairs(bindings) + getproperty(origins, name) == :model_spec || continue + selector isa Union{One,OptionalOne} || continue + selector_criteria = criteria(selector) + isnothing(_criteria_get(selector_criteria, :process, nothing)) && + continue + isnothing(_criteria_get(selector_criteria, :application, nothing)) || + continue + error( + "`process=` in scenario `ModelSpec(...; $(field)=...)` is not ", + "supported for `One` or `OptionalOne`. Name the target ", + "application and use `application=...`; model-authored `Input` ", + "and `Call` defaults may still use `process=...`.", + ) + end + return nothing +end + +function _replace_model_spec( + spec::ModelSpec; + model=spec.model, + name=spec.name, + on=spec.applies_to, + inputs=spec.inputs, + input_origins=spec.input_origins, + calls=spec.calls, + call_origins=spec.call_origins, + environment=spec.environment, + every=spec.timestep, + environment_bindings=spec.environment_bindings, + environment_window=spec.environment_window, + output_routing=spec.output_routing, + updates=spec.updates, +) + return _build_model_spec( + model; + name=name, + on=on, + inputs=inputs, + input_origins=input_origins, + calls=calls, + call_origins=call_origins, + environment=environment, + every=every, + environment_bindings=environment_bindings, + environment_window=environment_window, + output_routing=output_routing, + updates=updates, + ) +end + +as_model_spec(spec::ModelSpec) = spec +as_model_spec(model::AbstractModel) = ModelSpec(model) + +function _normalize_environment_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_environment_bindings(bindings::NamedTuple) + normalized = Pair{Symbol,Any}[] + for (k, v) in pairs(bindings) + push!(normalized, k => _normalize_environment_binding(v)) + end + return (; normalized...) +end + +function _normalize_environment_bindings(bindings) + error("Unsupported environment bindings `$(bindings)` of type `$(typeof(bindings))`.") +end + +function _normalize_environment_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 output routing value `$(routing)` of type `$(typeof(routing))`. ", + "Use a NamedTuple, e.g. `output_routing=(x=:stream_only,)`.", + ) +end + +""" + Environment(config) + Environment(; kwargs...) + +Environment configuration for a [`ModelSpec`](@ref). + +Pass the result directly with `environment=Environment(...)`. +""" +Environment(config) = config isa EnvironmentConfig ? config : EnvironmentConfig(config) +Environment(; kwargs...) = Environment((; kwargs...)) + +_normalize_model_environment(::Nothing) = nothing +_normalize_model_environment(config::EnvironmentConfig) = config +function _normalize_model_environment(config) + error( + "Unsupported model environment configuration `$(config)` of type ", + "`$(typeof(config))`. Use `environment=Environment(...)`.", + ) +end + +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 +environment_inputs_(m::ModelSpec) = environment_inputs_(model_(m)) +environment_outputs_(m::ModelSpec) = environment_outputs_(model_(m)) + +function run!(m::ModelSpec, status, environment, constants=nothing, context=nothing) + return run!(model_(m), status, environment, constants, context) +end diff --git a/src/PlantSimEngine.jl b/src/PlantSimEngine.jl index 1151b8f7a..86476595d 100644 --- a/src/PlantSimEngine.jl +++ b/src/PlantSimEngine.jl @@ -1,147 +1,312 @@ 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") +include("input_schema.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_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") +""" + 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, + bindings_dirty, + environment_bindings_dirty, + model_revision, + environment_revision, + compiled_bindings, + compiled_environment_bindings, + RuntimePerformanceCounters, + runtime_performance + +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 +export bindings_dirty, environment_bindings_dirty +export model_revision, environment_revision +export compiled_bindings, compiled_environment_bindings +export RuntimePerformanceCounters, runtime_performance +end + +""" + PlantSimEngine.Diagnostics + +Structured compiler/runtime explanations and supported carrier inspection. +""" +module Diagnostics +import ..PlantSimEngine: + ObjectAddress, + object_address, + explain_objects, + explain_instances, + explain_scopes, + explain_applications, + explain_bindings, + explain_calls, + explain_writers, + input_carrier, + input_value, + has_reference_carrier, + explain_outputs, + explain_initialization, + explain_execution_plan, + explain_output_retention, + explain_environment_bindings, + explain_schedule, + explain_environment + +export ObjectAddress, object_address +export explain_objects, explain_instances, explain_scopes +export explain_applications, explain_bindings, explain_calls, explain_writers +export input_carrier, input_value, has_reference_carrier +export explain_outputs, explain_initialization, explain_execution_plan +export explain_output_retention, explain_environment_bindings +export explain_schedule, explain_environment +end + +""" + PlantSimEngine.GraphEditor + +Static graph DTOs, model discovery, graph edits, and interactive editor +sessions. +""" +module GraphEditor +import ..PlantSimEngine: + available_processes, + available_models, + model_descriptor, + model_constructor_descriptor, + ModelGraphDiagnostic, + CompositeModelCompilationReport, + ModelGraphView, + compile_model_report, + compile_model_graph, + model_graph_view, + model_graph_view_json, + model_graph_view_html, + write_model_graph_view, + AbstractModelGraphEdit, + AddModelApplication, + RemoveModelApplication, + RemoveModelTemplateApplication, + ReplaceModelApplicationModel, + UpdateModelApplication, + UpdateModelTemplateApplication, + RenameModelApplication, + SetModelApplicationTargets, + SetModelInputBinding, + RemoveModelInputBinding, + SetModelCallBinding, + RemoveModelCallBinding, + SetModelApplicationTimeStep, + SetModelApplicationEnvironment, + SetModelOutputRouting, + SetModelUpdateOrdering, + MarkModelPreviousTimeStep, + UnmarkModelPreviousTimeStep, + BreakModelCycle, + AddModelObject, + RemoveModelObject, + ReparentModelObject, + SetModelObjectStatus, + SetModelObjectStatuses, + RemoveModelObjectStatus, + SetModelObjectMetadata, + SetModelInstanceOverride, + RemoveModelInstanceOverride, + SetModelObjectOverride, + RemoveModelObjectOverride, + apply_model_graph_edit, + AbstractModelGraphEditorSession, + edit_graph, + current_model, + apply_edit!, + undo!, + redo! + +export available_processes, available_models +export 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 +export RemoveModelTemplateApplication, ReplaceModelApplicationModel +export UpdateModelApplication, UpdateModelTemplateApplication +export RenameModelApplication, SetModelApplicationTargets +export SetModelInputBinding, RemoveModelInputBinding +export SetModelCallBinding, RemoveModelCallBinding +export SetModelApplicationTimeStep, SetModelApplicationEnvironment +export SetModelOutputRouting, SetModelUpdateOrdering +export MarkModelPreviousTimeStep, UnmarkModelPreviousTimeStep, BreakModelCycle +export AddModelObject, RemoveModelObject, ReparentModelObject +export SetModelObjectStatus, SetModelObjectStatuses, RemoveModelObjectStatus +export SetModelObjectMetadata, SetModelInstanceOverride, RemoveModelInstanceOverride +export SetModelObjectOverride, RemoveModelObjectOverride, apply_model_graph_edit +export AbstractModelGraphEditorSession, edit_graph, current_model +export apply_edit!, undo!, redo! +end + +""" + PlantSimEngine.EnvironmentAPI + +Extension protocol for global and spatial environment backends. +""" +module EnvironmentAPI +import ..PlantSimEngine: + AbstractEnvironmentBackend, + EnvironmentContext, + GlobalConstant, + environment_backend, + environment_variables, + base_step_seconds, + get_nsteps, + bind_environment, + sample, + sample_environment, + update_index!, + commit_environment! + +export AbstractEnvironmentBackend, EnvironmentContext, GlobalConstant +export environment_backend, environment_variables, base_step_seconds +export get_nsteps, bind_environment, sample, sample_environment, update_index! +export commit_environment! +end + +""" + PlantSimEngine.Evaluation + +Generic model-fitting interface and simulation evaluation metrics. +""" +module Evaluation +import ..PlantSimEngine: fit, RMSE, NRMSE, EF, dr + +export fit, RMSE, NRMSE, EF, dr +end -# Examples +# Examples are loaded after the public namespaces so shipped extension examples +# use the same qualified API as downstream packages. include("examples_import.jl") 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 RMSE, NRMSE, EF, dr -export Status, TimeStepTable, status -export init_status! -export add_organ! +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 +export geometry, position, bounds +export RunContext, CallTarget, CallTargets, Simulation +export runtime_model, current_step, final_state, outputs +export SceneScope, Self, Subtree, SelfPlant, Ancestor, Scope, Relation +export One, OptionalOne, Many +export Input, Call, Environment +export application_name, applies_to, value_inputs, model_calls, environment_config +export ModelSpec, Updates +export call_targets, run_call!, commit_environment! +export Status +export Required, Default export @process, process -export to_initialize, is_initialized, init_variables, dep -export inputs, outputs, variables, convert_outputs -export timespec, output_policy, timestep_hint, meteo_hint -export input_bindings, meteo_bindings, meteo_window, output_routing, model_scope -export run! -export fit +export init_variables, dep +export inputs, outputs, variables +export timespec, output_policy, timestep_hint, environment_hint +export environment_bindings, environment_window, output_routing, updates +export environment_inputs, environment_outputs +export validate_environment_inputs +export run!, continue!, step! +export Advanced, Diagnostics, GraphEditor, EnvironmentAPI, Evaluation # 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..457d6d743 --- /dev/null +++ b/src/composite_model/compilation.jl @@ -0,0 +1,3511 @@ +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} + order_after_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 CompiledTemporalInput{B,S,I,R} + binding::B + source_applications::S + initial::I + reference::R +end + +struct CompiledModelStatusView{S,C,T,P} + status::S + canonical_status::C + temporal_inputs::T + private_outputs::P +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,H,C} + application_id::Symbol + object_id::ObjectId + backend::B + handle::H + required_inputs::Vector{Symbol} + source_inputs::Vector{Symbol} + produced_outputs::Vector{Symbol} + context::C + geometry_source_object_id::Union{Nothing,ObjectId} + geometry_source::Symbol + config::Any +end + +struct CompiledEnvironmentBindings{SC,B,I,PI,S,P,C} + model::SC + bindings::B + by_target::I + positions_by_target::PI + samplers_by_application::S + prepared_global_environment::P + sample_cache::C + model_revision::Int + environment_revision::Int + applications_identity::UInt +end + +struct CompiledCompositeModel{SC,AP,AI,ABO,IB,CB,IBI,CBI,DBI,DCBI,MBC,CO,AC,SVI,CE,CET,PA,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 + dynamic_call_binding_indices::DCBI + many_input_binding_cache::MBC + call_owners::CO + application_children::AC + status_views_by_target::SVI + changed_execution_application_ids::CE + changed_execution_target_ids::CET + status_view_refresh_is_pure_addition::PA + 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) + isnothing(scale) && return Union{Nothing,Symbol}[nothing] + return Symbol[Symbol(value) for value in (scale isa Tuple ? scale : (scale,))] +end + +function _index_dynamic_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 + +_index_dynamic_input_bindings(model::CompositeModel, bindings) = + _index_dynamic_bindings(model, bindings) +_index_dynamic_call_bindings(model::CompositeModel, bindings) = + _index_dynamic_bindings(model, bindings) + +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; performance=nothing) + return compile_composite_model( + model, + model.applications; + performance=performance, + ) +end + +function compile_composite_model( + model::CompositeModel, + specs::Tuple; + performance=nothing, +) + return _compile_scene(model, specs; performance=performance) +end + +function compile_composite_model( + model::CompositeModel, + specs::AbstractVector; + performance=nothing, +) + return _compile_scene(model, Tuple(specs); performance=performance) +end + +function compile_composite_model( + model::CompositeModel, + specs...; + performance=nothing, +) + return _compile_scene(model, specs; performance=performance) +end + +function _model_timeline(model::CompositeModel) + backend = environment_backend(model.environment) + _validate_environment_duration(backend) + return _timeline_context(backend) +end + +function _compile_scene( + model::CompositeModel, + raw_specs; + validate_required_inputs::Bool=true, + performance=nothing, +) + started_at = _runtime_performance_start(performance) + timeline = _model_timeline(model) + applications = _compile_model_applications(model, raw_specs, timeline) + _runtime_performance_finish!( + performance, + :application_target_compile, + started_at, + ) + started_at = _runtime_performance_start(performance) + call_bindings = _compile_model_call_bindings(model, applications) + _validate_model_call_cadences!(applications, call_bindings, timeline) + _runtime_performance_finish!( + performance, + :call_binding_compile, + started_at, + ) + _validate_model_writers!(applications, call_bindings) + _prepare_model_output_statuses!(model, applications) + started_at = _runtime_performance_start(performance) + input_bindings = _compile_model_input_bindings( + model, + applications, + _manual_call_application_ids(call_bindings), + ) + many_input_binding_cache = + _share_many_input_bindings!(model, input_bindings) + _prepare_model_input_defaults!(model, applications) + _wire_model_input_carriers!(model, input_bindings) + validate_required_inputs && + _validate_model_required_inputs!(model, applications, input_bindings) + _runtime_performance_finish!( + performance, + :input_binding_compile, + started_at, + ) + # Lifecycle refresh may replace an initially empty `Many(...)` carrier + # with a concrete carrier type. Keep the per-target index type-stable + # across that replacement so an updated compiled scene can be stored in + # an existing `Simulation`. + input_bindings_by_target = Dict{Any,Any}( + _index_model_bindings( + input_bindings, + :application_id, + :consumer_id, + ), + ) + call_bindings_by_target = _index_model_bindings(call_bindings, :application_id, :consumer_id) + started_at = _runtime_performance_start(performance) + call_owners = _model_call_owners(call_bindings) + application_children = _compile_model_application_children( + applications, + input_bindings, + call_owners, + ) + application_order = _stable_topological_application_order( + applications, + application_children, + ) + _runtime_performance_finish!( + performance, + :application_order_compile, + started_at, + ) + applications_by_id = Dict(application.id => application for application in applications) + started_at = _runtime_performance_start(performance) + status_views_by_target = _compile_model_status_views( + model, + applications, + applications_by_id, + input_bindings_by_target, + application_order, + ) + _runtime_performance_finish!( + performance, + :status_view_compile, + started_at, + ) + 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), + _index_dynamic_call_bindings(model, call_bindings), + many_input_binding_cache, + call_owners, + application_children, + status_views_by_target, + Set(application.id for application in applications), + Set(keys(status_views_by_target)), + false, + 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 _sorted_object_id_position(ids, object_id::ObjectId) + lower = firstindex(ids) + upper = lastindex(ids) + while lower <= upper + middle = (lower + upper) >>> 1 + candidate = @inbounds ids[middle] + candidate == object_id && return (true, middle) + if _object_id_isless(candidate, object_id) + lower = middle + 1 + else + upper = middle - 1 + end + end + return (false, lower) +end + +function _insert_with_spare_capacity!(values::Vector, position::Integer, value) + old_length = length(values) + 1 <= position <= old_length + 1 || + throw(BoundsError(values, position)) + push!(values, value) + position == old_length + 1 && return values + copyto!( + values, + position + 1, + values, + position, + old_length - position + 1, + ) + values[position] = value + return values +end + +function _insert_sorted_object_ids!(ids, added_ids) + isempty(added_ids) && return ids + sizehint!(ids, length(ids) + length(added_ids)) + for object_id in added_ids + found, position = _sorted_object_id_position(ids, object_id) + found || _insert_with_spare_capacity!(ids, position, object_id) + end + return ids +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.order_after_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 = if _selector_from_status(binding.selector) + Symbol[] + else + _matching_input_source_applications( + applications_by_object, + new_source_ids, + binding.source_var, + binding.process, + binding.application; + allow_empty=binding.selector isa OptionalOne, + ) + end + 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 _update_structural_many_sources!( + model::CompositeModel, + binding::CompiledModelInputBinding, + dirty_ids, +) + binding.multiplicity == :many || return :fallback + binding.carrier_hint == :ref_vector || return :fallback + isnothing(binding.application) && return :fallback + carrier_references = parent(binding.carrier) + default_scope = _default_dependency_scope(model, binding.consumer_id) + changes = Tuple{ObjectId,Bool,Any}[] + for object_id in dirty_ids + was_source, _ = + _sorted_object_id_position(binding.source_ids, object_id) + is_source = haskey(model.registry.objects, object_id) && + _selector_matches_object_id( + model, + binding.selector, + object_id; + context=binding.consumer_id, + default_to_context=true, + default_scope=default_scope, + ) + was_source == is_source && continue + source_reference = nothing + if is_source + source = _model_object(model, object_id) + source_reference = + _status_ref_or_nothing(source.status, binding.source_var) + isnothing(source_reference) && return :fallback + source_reference isa eltype(carrier_references) || + return :fallback + end + push!(changes, (object_id, is_source, source_reference)) + end + for (object_id, is_source, source_reference) in changes + was_source, position = + _sorted_object_id_position(binding.source_ids, object_id) + if was_source + deleteat!(binding.source_ids, position) + deleteat!(carrier_references, position) + elseif is_source + _insert_with_spare_capacity!( + binding.source_ids, + position, + object_id, + ) + _insert_with_spare_capacity!( + carrier_references, + position, + source_reference, + ) + end + end + return isempty(changes) ? :unchanged : :updated +end + +function _preserve_temporal_input_state!( + current::CompiledTemporalInput, + previous::CompiledTemporalInput, + previous_source_ids, +) + if current.binding.multiplicity != :many || + previous.binding.multiplicity != :many + current.reference[] = _private_temporal_value(previous.reference[]) + return CompiledTemporalInput( + current.binding, + current.source_applications, + previous.initial, + current.reference, + ) + end + + current_initial = current.initial + current_storage = current.reference[] + previous_initial = previous.initial + previous_storage = previous.reference[] + previous_indices = Dict( + object_id => index + for (index, object_id) in pairs(previous_source_ids) + ) + for (current_index, object_id) in pairs(current.binding.source_ids) + previous_index = get(previous_indices, object_id, nothing) + isnothing(previous_index) && continue + current_initial[current_index] = + _private_temporal_value(previous_initial[previous_index]) + current_storage[current_index] = + _private_temporal_value(previous_storage[previous_index]) + end + return CompiledTemporalInput( + current.binding, + current.source_applications, + current_initial, + current.reference, + ) +end + +function _preserve_model_status_view_temporal_state!( + current::CompiledModelStatusView, + previous::CompiledModelStatusView, + previous_temporal_sources, + key, +) + previous_by_input = Dict( + temporal_input.binding.input => temporal_input + for temporal_input in previous.temporal_inputs + ) + temporal_inputs = Tuple(begin + previous_input = get( + previous_by_input, + temporal_input.binding.input, + nothing, + ) + if isnothing(previous_input) + temporal_input + else + previous_source_ids = get( + previous_temporal_sources, + (key..., temporal_input.binding.input), + previous_input.binding.source_ids, + ) + _preserve_temporal_input_state!( + temporal_input, + previous_input, + previous_source_ids, + ) + end + end for temporal_input in current.temporal_inputs) + temporal_by_name = Dict( + temporal_input.binding.input => temporal_input + for temporal_input in temporal_inputs + ) + private_output_names = propertynames(current.private_outputs) + private_output_references = ntuple( + index -> begin + name = private_output_names[index] + return hasproperty(previous.private_outputs, name) ? + getproperty(previous.private_outputs, name) : + getproperty(current.private_outputs, name) + end, + length(private_output_names), + ) + private_outputs = + NamedTuple{private_output_names}(private_output_references) + names = propertynames(current.status) + references = ntuple(length(names)) do index + name = names[index] + temporal_input = get(temporal_by_name, name, nothing) + isnothing(temporal_input) || return temporal_input.reference + hasproperty(private_outputs, name) && + return getproperty(private_outputs, name) + return refvalue(current.canonical_status, name) + end + status = Status(NamedTuple{names}(references)) + return CompiledModelStatusView( + status, + current.canonical_status, + temporal_inputs, + private_outputs, + ) +end + +function _extend_model_status_views( + model::CompositeModel, + compiled::CompiledCompositeModel, + applications, + applications_by_id, + applications_by_object, + input_bindings_by_target, + application_order, + new_targets, + rewired_consumer_ids, + affected_temporal_keys, + previous_temporal_sources, + previous_views=compiled.status_views_by_target, +) + views = compiled.status_views_by_target + affected_keys = Set{Tuple{Symbol,ObjectId}}(affected_temporal_keys) + for application in applications + for object_id in get(new_targets, application.id, ObjectId[]) + push!(affected_keys, (application.id, object_id)) + end + end + for object_id in rewired_consumer_ids + for application in get(applications_by_object, object_id, ()) + push!(affected_keys, (application.id, object_id)) + end + end + + positions = Dict( + application_id => index + for (index, application_id) in pairs(application_order) + ) + for key in affected_keys + application_id, object_id = key + application = applications_by_id[application_id] + previous_view = get(previous_views, key, nothing) + if !isnothing(previous_view) + for temporal_input in previous_view.temporal_inputs + get!( + previous_temporal_sources, + (key..., temporal_input.binding.input), + ) do + copy(temporal_input.binding.source_ids) + end + end + end + current_view = _compile_model_status_view( + model, + application, + object_id, + get(input_bindings_by_target, key, ()), + applications_by_id, + positions, + ) + views[key] = isnothing(previous_view) ? + current_view : + _preserve_model_status_view_temporal_state!( + current_view, + previous_view, + previous_temporal_sources, + key, + ) + end + return views +end + +function _append_added_many_call_targets!( + model::CompositeModel, + binding::CompiledModelCallBinding, + added_ids, + applications_by_object, +) + binding.multiplicity == :many || return false + default_scope = _default_dependency_scope(model, binding.consumer_id) + new_target_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.callee_object_ids) + ] + isempty(new_target_ids) && return true + _sort_object_ids!(new_target_ids) + + # Monotonically increasing lifecycle IDs preserve the selector's compiled + # stable order. Explicit non-monotonic IDs use the general rebuild path. + if !isempty(binding.callee_object_ids) && + !_object_id_isless( + last(binding.callee_object_ids), + first(new_target_ids), + ) + return false + end + + append!(binding.callee_object_ids, new_target_ids) + for object_id in new_target_ids + for application_id in _matching_callee_applications( + applications_by_object, + object_id, + binding.process, + binding.application, + ) + application_id in binding.callee_application_ids || + push!(binding.callee_application_ids, application_id) + end + end + return true +end + +function _prepare_structural_compiled_delta( + model::CompositeModel, + compiled::CompiledCompositeModel, + dirty_object_ids, + performance=nothing, +) + started_at = _runtime_performance_start(performance) + dirty = Set{ObjectId}(dirty_object_ids) + removed_target_keys = Set{Tuple{Symbol,ObjectId}}() + changed_application_ids = Set{Symbol}() + applications_by_object = compiled.applications_by_object + for object_id in dirty + for application in get(applications_by_object, object_id, ()) + found, position = _sorted_object_id_position( + application.target_ids, + object_id, + ) + found || continue + push!(removed_target_keys, (application.id, object_id)) + push!(changed_application_ids, application.id) + deleteat!(application.target_ids, position) + end + end + + for object_id in dirty + delete!(applications_by_object, object_id) + end + + removed_input_consumers = Set{Tuple{Symbol,ObjectId,Symbol}}() + input_bindings = CompiledModelInputBinding[] + sizehint!(input_bindings, length(compiled.input_bindings)) + for binding in compiled.input_bindings + if binding.consumer_id in dirty + push!( + removed_input_consumers, + (binding.application_id, binding.consumer_id, binding.input), + ) + continue + end + push!(input_bindings, binding) + end + forced_input_binding_keys = Set{Tuple{Symbol,ObjectId,Symbol}}() + previous_temporal_sources = Dict{ + Tuple{Symbol,ObjectId,Symbol}, + Vector{ObjectId}, + }() + for binding in input_bindings + any(dirty_id -> first(_sorted_object_id_position(binding.source_ids, dirty_id)), dirty) || + continue + key = (binding.application_id, binding.consumer_id, binding.input) + push!(forced_input_binding_keys, key) + if binding.carrier_hint == :temporal_stream + previous_temporal_sources[key] = copy(binding.source_ids) + end + end + input_bindings_by_target = Dict{Any,Any}( + _index_model_bindings( + input_bindings, + :application_id, + :consumer_id, + ), + ) + + removed_call_consumers = Set{Tuple{Symbol,ObjectId}}() + call_bindings = CompiledModelCallBinding[] + sizehint!(call_bindings, length(compiled.call_bindings)) + for binding in compiled.call_bindings + key = (binding.application_id, binding.consumer_id) + if binding.consumer_id in dirty + push!(removed_call_consumers, key) + continue + end + push!(call_bindings, binding) + end + forced_call_target_keys = Set{Tuple{Symbol,ObjectId}}() + for binding in call_bindings + any( + dirty_id -> first( + _sorted_object_id_position( + binding.callee_object_ids, + dirty_id, + ), + ), + dirty, + ) || + continue + push!( + forced_call_target_keys, + (binding.application_id, binding.consumer_id), + ) + end + call_bindings_by_target = _index_model_bindings( + call_bindings, + :application_id, + :consumer_id, + ) + + stripped = CompiledCompositeModel( + model, + compiled.applications, + compiled.applications_by_id, + applications_by_object, + input_bindings, + call_bindings, + input_bindings_by_target, + call_bindings_by_target, + _index_dynamic_input_bindings(model, input_bindings), + _index_dynamic_call_bindings(model, call_bindings), + _many_input_binding_cache(model, input_bindings), + compiled.call_owners, + compiled.application_children, + compiled.status_views_by_target, + changed_application_ids, + removed_target_keys, + false, + compiled.application_order, + compiled.timeline, + model.revision, + ) + result = ( + compiled=stripped, + forced_input_binding_keys=forced_input_binding_keys, + forced_call_target_keys=forced_call_target_keys, + previous_temporal_sources=previous_temporal_sources, + changed_application_ids=changed_application_ids, + changed_target_ids=removed_target_keys, + graph_may_shrink=!isempty(removed_input_consumers), + call_graph_may_shrink=!isempty(removed_call_consumers), + ) + _runtime_performance_finish!( + performance, + :structural_delta_prepare, + started_at, + ) + return result +end + +function _remove_stale_status_views!( + compiled::CompiledCompositeModel, + candidate_keys, +) + for key in candidate_keys + application_id, object_id = key + application = compiled.applications_by_id[application_id] + object_id in application.target_ids && continue + delete!(compiled.status_views_by_target, key) + end + return compiled +end + +function _extend_compiled_scene( + model::CompositeModel, + compiled::CompiledCompositeModel, + added_objects; + forced_input_binding_keys=Set{Tuple{Symbol,ObjectId,Symbol}}(), + forced_call_target_keys=Set{Tuple{Symbol,ObjectId}}(), + previous_views=compiled.status_views_by_target, + previous_temporal_sources_seed=Dict{ + Tuple{Symbol,ObjectId,Symbol}, + Vector{ObjectId}, + }(), + changed_application_ids_seed=Set{Symbol}(), + changed_target_ids_seed=Set{Tuple{Symbol,ObjectId}}(), + application_graph_may_shrink::Bool=false, + recompute_call_owners::Bool=false, + pure_addition::Bool=true, + structural_dirty_ids=ObjectId[], + performance=nothing, +) + added_ids = ObjectId[id for id in added_objects if haskey(model.registry.objects, id)] + isempty(added_ids) && + isempty(forced_input_binding_keys) && + isempty(forced_call_target_keys) && + isempty(changed_application_ids_seed) && + !application_graph_may_shrink && + !recompute_call_owners && + return compile_composite_model( + model, + model.applications; + performance=performance, + ) + started_at = _runtime_performance_start(performance) + new_targets = _new_application_targets(model, compiled.applications, added_ids) + isnothing(new_targets) && return compile_composite_model( + model, + model.applications; + performance=performance, + ) + + for application in compiled.applications + _insert_sorted_object_ids!( + application.target_ids, + get(new_targets, application.id, ObjectId[]), + ) + if application.id in changed_application_ids_seed + target_count = length(application.target_ids) + if application.applies_to isa One && target_count != 1 + error( + "Lifecycle refresh left application `$(application.id)` with ", + "$(target_count) targets for a `One` selector.", + ) + elseif application.applies_to isa OptionalOne && target_count > 1 + error( + "Lifecycle refresh left application `$(application.id)` with ", + "$(target_count) targets for an `OptionalOne` selector.", + ) + end + end + end + applications = compiled.applications + applications_by_id = compiled.applications_by_id + 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 + _runtime_performance_finish!( + performance, + :application_target_refresh, + started_at, + ) + + started_at = _runtime_performance_start(performance) + has_calls = any(applications) do application + calls = model_calls(application.spec) + calls isa NamedTuple && !isempty(keys(calls)) + end + timeline = compiled.timeline + 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 + rebuilt_existing_call_targets = + Set{Tuple{Symbol,ObjectId}}(forced_call_target_keys) + if has_calls + candidate_call_binding_indices = + Set(get(compiled.dynamic_call_binding_indices, nothing, Int[])) + for object_id in added_ids + object_scale = _model_object(model, object_id).scale + isnothing(object_scale) || union!( + candidate_call_binding_indices, + get( + compiled.dynamic_call_binding_indices, + object_scale, + Int[], + ), + ) + end + for binding_index in candidate_call_binding_indices + binding = compiled.call_bindings[binding_index] + _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), + ) || continue + key = (binding.application_id, binding.consumer_id) + appended = key in forced_call_target_keys ? + false : + _append_added_many_call_targets!( + model, + binding, + added_ids, + applications_by_object, + ) + appended || push!(rebuilt_existing_call_targets, key) + end + end + new_call_bindings = has_calls ? + _compile_model_call_bindings( + model, + added_applications, + applications, + ; + by_object=applications_by_object, + ) : CompiledModelCallBinding[] + affected_call_applications = CompiledModelApplication[] + if !isempty(rebuilt_existing_call_targets) + for application in applications + target_ids = ObjectId[ + object_id for object_id in application.target_ids + if (application.id, object_id) in + rebuilt_existing_call_targets + ] + isempty(target_ids) && continue + push!( + affected_call_applications, + CompiledModelApplication( + application.id, + application.spec, + application.process, + application.name, + target_ids, + application.applies_to, + application.timestep, + application.clock, + application.model_overrides, + ), + ) + end + end + replacement_call_bindings = isempty(affected_call_applications) ? + CompiledModelCallBinding[] : + _compile_model_call_bindings( + model, + affected_call_applications, + applications; + by_object=applications_by_object, + ) + replacement_call_bindings_by_target = _index_model_bindings( + replacement_call_bindings, + :application_id, + :consumer_id, + ) + call_bindings = compiled.call_bindings + for key in rebuilt_existing_call_targets + existing = get(compiled.call_bindings_by_target, key, ()) + replacements = get(replacement_call_bindings_by_target, key, ()) + length(existing) == length(replacements) || error( + "Incremental hard-call refresh changed the number of declared calls ", + "for application `$(first(key))` on object `$(last(key).value)`.", + ) + for binding in existing + replacement_index = findfirst( + candidate -> + _compiled_call_name(candidate) === + _compiled_call_name(binding), + replacements, + ) + isnothing(replacement_index) && error( + "Incremental hard-call refresh lost declared call ", + "`$(_compiled_call_name(binding))` for application ", + "`$(first(key))` on object `$(last(key).value)`.", + ) + replacement = replacements[replacement_index] + empty!(binding.callee_object_ids) + append!(binding.callee_object_ids, replacement.callee_object_ids) + empty!(binding.callee_application_ids) + append!( + binding.callee_application_ids, + replacement.callee_application_ids, + ) + end + end + append!(call_bindings, new_call_bindings) + dynamic_call_binding_indices = compiled.dynamic_call_binding_indices + first_new_call_binding = length(call_bindings) - length(new_call_bindings) + 1 + for binding_index in first_new_call_binding:length(call_bindings) + binding = call_bindings[binding_index] + for scale in _dynamic_binding_scale_keys(model, binding) + push!( + get!(dynamic_call_binding_indices, scale, Int[]), + binding_index, + ) + end + end + _validate_model_call_cadences!( + applications, + (replacement_call_bindings..., new_call_bindings...), + timeline, + ) + _validate_model_writers_for_objects!( + applications, + applications_by_object, + added_ids, + call_bindings, + ) + _prepare_model_output_statuses!(model, added_applications) + _runtime_performance_finish!( + performance, + :call_binding_refresh, + started_at, + ) + + started_at = _runtime_performance_start(performance) + manual_application_ids = _manual_call_application_ids(call_bindings) + added_input_binding_capacity = sum( + length(_model_input_names(application)) * + length(get(new_targets, application.id, ObjectId[])) + for application in applications + ) + input_bindings = compiled.input_bindings + sizehint!( + input_bindings, + length(compiled.input_bindings) + added_input_binding_capacity, + ) + # A `Many(...)` binding that starts empty is compiled with an untyped + # `RefVector{Any}` carrier. Once matching objects are registered, the + # refreshed carrier becomes concrete (for example `RefVector{Float64}`). + # Keep this incremental index value-widened so that lifecycle refresh can + # replace an initially empty binding without rebuilding the whole scene. + input_bindings_by_target = compiled.input_bindings_by_target + many_binding_cache = compiled.many_input_binding_cache + changed_bindings = CompiledModelInputBinding[] + order_changed_bindings = CompiledModelInputBinding[] + rewired_consumer_ids = Set{ObjectId}() + affected_temporal_keys = Set{Tuple{Symbol,ObjectId}}() + previous_temporal_sources = copy(previous_temporal_sources_seed) + processed_many_sources = IdDict{Any,Nothing}() + previous_shared_many_sources = IdDict{Any,Vector{ObjectId}}() + application_edges_may_shrink = + application_graph_may_shrink || + !isempty(forced_input_binding_keys) + candidate_binding_indices = Set(get(compiled.dynamic_input_binding_indices, nothing, Int[])) + if !isempty(forced_input_binding_keys) + for (binding_index, binding) in pairs(input_bindings) + key = (binding.application_id, binding.consumer_id, binding.input) + key in forced_input_binding_keys && + push!(candidate_binding_indices, binding_index) + end + end + 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] + force_rebuild = + !isempty(forced_input_binding_keys) && + ( + binding.application_id, + binding.consumer_id, + binding.input, + ) in forced_input_binding_keys + if binding.multiplicity == :many && haskey(processed_many_sources, binding.source_ids) + if binding.carrier_hint == :temporal_stream + key = (binding.application_id, binding.consumer_id) + push!(affected_temporal_keys, key) + previous_temporal_sources[( + key..., + binding.input, + )] = previous_shared_many_sources[binding.source_ids] + end + continue + end + previous_source_ids = binding.carrier_hint == :temporal_stream ? + copy(binding.source_ids) : + ObjectId[] + previous_source_application_ids = + copy(binding.source_application_ids) + binding.multiplicity == :many && + binding.carrier_hint == :temporal_stream && + (previous_shared_many_sources[binding.source_ids] = copy(binding.source_ids)) + default_scope = _default_dependency_scope(model, binding.consumer_id) + if !pure_addition + structural_update = _update_structural_many_sources!( + model, + binding, + structural_dirty_ids, + ) + if structural_update != :fallback + processed_many_sources[binding.source_ids] = nothing + continue + end + end + if !force_rebuild + _selector_matches_any_object_id( + model, + binding.selector, + added_ids; + context=binding.consumer_id, + default_to_context=true, + default_scope=default_scope, + ) || continue + end + appended_sources = if force_rebuild + false + else + _append_added_many_sources!( + model, + binding, + added_ids, + applications_by_object, + ) + end + if appended_sources + previous_source_application_ids != + binding.source_application_ids && + push!(order_changed_bindings, binding) + if binding.carrier_hint == :temporal_stream && + previous_source_ids != binding.source_ids + key = (binding.application_id, binding.consumer_id) + push!(affected_temporal_keys, key) + previous_temporal_sources[( + key..., + binding.input, + )] = previous_source_ids + end + 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, + ) + old_cache_key = _many_binding_share_key(model, binding) + if !isnothing(old_cache_key) && + get(many_binding_cache, old_cache_key, nothing) === binding + delete!(many_binding_cache, old_cache_key) + end + replacement_binding = _share_many_input_binding!( + many_binding_cache, + model, + only(replacement), + ) + issubset( + previous_source_application_ids, + replacement_binding.source_application_ids, + ) || (application_edges_may_shrink = true) + input_bindings[index] = replacement_binding + push!(changed_bindings, replacement_binding) + push!(order_changed_bindings, replacement_binding) + push!(rewired_consumer_ids, binding.consumer_id) + if binding.carrier_hint == :temporal_stream + key = (binding.application_id, binding.consumer_id) + push!(affected_temporal_keys, key) + previous_temporal_sources[( + key..., + binding.input, + )] = previous_source_ids + end + 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) + 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) + append!(order_changed_bindings, new_bindings) + input_bindings_by_target[(application.id, consumer_id)] = Tuple(new_bindings) + end + end + end + dynamic_input_binding_indices = 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 + + affected_input_applications = if pure_addition || + isempty(rewired_consumer_ids) + Tuple(added_applications) + else + rewired_applications = CompiledModelApplication[] + for application in applications + target_ids = ObjectId[ + object_id for object_id in application.target_ids + if object_id in rewired_consumer_ids + ] + isempty(target_ids) && continue + push!( + rewired_applications, + CompiledModelApplication( + application.id, + application.spec, + application.process, + application.name, + target_ids, + application.applies_to, + application.timestep, + application.clock, + application.model_overrides, + ), + ) + end + (added_applications..., rewired_applications...) + end + _prepare_model_input_defaults!(model, affected_input_applications) + _wire_model_input_carriers!(model, changed_bindings) + _validate_model_required_inputs!( + model, + affected_input_applications, + changed_bindings, + ) + _runtime_performance_finish!( + performance, + :input_binding_refresh, + started_at, + ) + call_bindings_by_target = compiled.call_bindings_by_target + isempty(new_call_bindings) || merge!( + call_bindings_by_target, + _index_model_bindings( + new_call_bindings, + :application_id, + :consumer_id, + ), + ) + started_at = _runtime_performance_start(performance) + call_owners = !recompute_call_owners && + isempty(forced_call_target_keys) ? + compiled.call_owners : + _model_call_owners(call_bindings) + call_owners_changed = + recompute_call_owners || !isempty(forced_call_target_keys) + for binding in call_bindings + for callee_id in binding.callee_application_ids + owners = get!(call_owners, callee_id, Set{Symbol}()) + binding.application_id in owners && continue + push!(owners, binding.application_id) + call_owners_changed = true + end + end + application_children = if !application_edges_may_shrink && + !call_owners_changed + children = Dict( + application_id => copy(child_ids) + for (application_id, child_ids) in compiled.application_children + ) + _model_input_order_edges!( + children, + order_changed_bindings, + call_owners, + ) + _model_update_order_edges!(children, added_applications) + else + _compile_model_application_children( + applications, + input_bindings, + call_owners, + ) + end + application_order = if application_children == + compiled.application_children + compiled.application_order + else + _stable_topological_application_order( + applications, + application_children, + ) + end + _runtime_performance_finish!( + performance, + :application_order_refresh, + started_at, + ) + if application_order != compiled.application_order + for (key, view) in compiled.status_views_by_target + isempty(view.temporal_inputs) && continue + push!(affected_temporal_keys, key) + for temporal_input in view.temporal_inputs + get!( + previous_temporal_sources, + (key..., temporal_input.binding.input), + ) do + copy(temporal_input.binding.source_ids) + end + end + end + end + started_at = _runtime_performance_start(performance) + status_views_by_target = _extend_model_status_views( + model, + compiled, + applications, + applications_by_id, + applications_by_object, + input_bindings_by_target, + application_order, + new_targets, + rewired_consumer_ids, + affected_temporal_keys, + previous_temporal_sources, + previous_views, + ) + _runtime_performance_finish!( + performance, + :status_view_refresh, + started_at, + ) + changed_execution_target_ids = Set{Tuple{Symbol,ObjectId}}( + (application_id, object_id) + for (application_id, object_ids) in new_targets + for object_id in object_ids + ) + union!( + changed_execution_target_ids, + affected_temporal_keys, + ) + union!(changed_execution_target_ids, changed_target_ids_seed) + for object_id in rewired_consumer_ids + union!( + changed_execution_target_ids, + ( + (application.id, object_id) + for application in get(applications_by_object, object_id, ()) + ), + ) + end + changed_execution_application_ids = Set( + first(key) for key in changed_execution_target_ids + ) + union!( + changed_execution_application_ids, + changed_application_ids_seed, + ) + status_view_refresh_is_pure_addition = pure_addition + 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, + dynamic_call_binding_indices, + many_binding_cache, + call_owners, + application_children, + status_views_by_target, + changed_execution_application_ids, + changed_execution_target_ids, + status_view_refresh_is_pure_addition, + 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) == :environment_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 `ModelSpec(...; every=...)` declarations or omit `every` 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 => setdiff( + Set{Symbol}( + object.status isa Status ? Symbol.(propertynames(object.status)) : Symbol[] + ), + get(model.input_default_status_variables, object.id, Set{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) + environment_model_outputs = environment_outputs_(application.spec) + model_inputs = _input_schema(application.spec) + environment_inputs = environment_inputs_(application.spec) + generated = Set(Symbol.(keys(model_outputs))) + for object_id in application.target_ids + for variable in sort!(collect(generated); by=string) + default_value = getproperty(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(environment_model_outputs))); by=string) + default_value = getproperty(environment_model_outputs, variable) + push!(rows, ( + application_id=application.id, + object_id=object_id.value, + variable=variable, + role=:environment_output, + disposition=:declared, + source_application_ids=Symbol[], + source_object_ids=Any[], + source_variable=nothing, + origin=:environment_commit, + expected_type=typeof(default_value), + default_value=default_value, + provided_type=nothing, + detail=nothing, + )) + end + for variable in sort!(Symbol.(collect(keys(model_inputs))); by=string) + key = (application.id, object_id, variable) + binding = get(bindings, key, nothing) + declaration = getproperty(model_inputs, variable) + binding_resolved = + !isnothing(binding) && + !(binding.carrier_hint == :optional_default && + isnothing(binding.carrier)) + missing_previous_initial = + binding_resolved && + binding.policy isa PreviousTimeStep && + declaration isa Required && + !(variable in get(supplied, object_id, Set{Symbol}())) + disposition = if missing_previous_initial + :required + elseif binding_resolved + :producer_bound + elseif variable in get(supplied, object_id, Set{Symbol}()) + :supplied + elseif declaration isa Default + :defaulted + else + :required + end + default_value = + declaration isa Default ? declaration.value : nothing + 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=binding_resolved ? + copy(binding.source_application_ids) : + Symbol[], + source_object_ids=binding_resolved ? + [id.value for id in binding.source_ids] : + Any[], + source_variable=binding_resolved ? binding.source_var : nothing, + origin=binding_resolved ? + binding.origin : + (disposition == :supplied ? :status : + disposition == :defaulted ? :model_default : :missing), + declaration=declaration isa Required ? :required : :defaulted, + expected_type=_input_expected_type(declaration), + default_value=default_value, + provided_type=provided_type, + detail=disposition == :required ? + "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(environment_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 `ModelSpec(...; on=...)` selector." + ) + selector isa AbstractObjectMultiplicity || error( + "`ModelSpec(...; on=...)` for process `$(process(spec))` must use 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) + sizehint!(target_ids, length(target_ids) + 1) + spec = _model_spec_with_environment_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_environment_hints(model::CompositeModel, spec, scale::Symbol) + hint = _normalize_environment_hint(scale, process(spec), environment_hint(model_(spec))) + + current_bindings = environment_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_environment_bindings_with_environment_sources(spec, new_bindings) + + current_window = environment_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 _replace_model_spec( + spec; + environment_bindings=new_bindings, + environment_window=new_window, + ) +end + +function _model_environment_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(environment_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 environment input(s) `$(Tuple(unknown))`. Declared ", + "`environment_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_environment_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 `on=...` 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 + +_update_matches_application(label::Symbol, application::CompiledModelApplication) = + label == application.id + +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) + isnothing(binding.application) || push!(ids, binding.application) + end + return ids +end + +function _validate_model_writer_groups!(writer_groups) + for ((object_id, variable), indexed_writers) in writer_groups + 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 _validate_model_writers!(applications, call_bindings=()) + manual_application_ids = _manual_call_application_ids(call_bindings) + return _validate_model_writer_groups!( + _model_writer_groups(applications, manual_application_ids), + ) +end + +function _validate_model_writers_for_objects!( + applications, + applications_by_object, + object_ids, + call_bindings=(), +) + isempty(object_ids) && return nothing + manual_application_ids = _manual_call_application_ids(call_bindings) + positions = Dict( + application.id => index + for (index, application) in pairs(applications) + ) + groups = Dict{Tuple{ObjectId,Symbol},Vector{Tuple{Int,Any}}}() + for object_id in object_ids + for application in get(applications_by_object, object_id, ()) + application.id in manual_application_ids && continue + for variable in _model_canonical_output_names(application) + push!( + get!( + groups, + (object_id, variable), + Tuple{Int,Any}[], + ), + (positions[application.id], application), + ) + end + end + end + return _validate_model_writer_groups!(groups) +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 == :environment_base_step || return _model_clock(spec, process_model, timeline) + scale = _model_application_hint_scale(model, target_ids) + clock, hint_reason = + _resolve_environment_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 the `inputs=...` selector." + ) +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 _selector_from_status(selector::AbstractObjectMultiplicity) + from_status = _criteria_get(criteria(selector), :from_status, false) + from_status isa Bool || error( + "Selector keyword `from_status` must be `true` or `false`, got `$(repr(from_status))`." + ) + return from_status +end + +function _selector_order_after(selector::AbstractObjectMultiplicity) + after = _criteria_get(criteria(selector), :after, nothing) + isnothing(after) && return Symbol[] + values = after isa Union{Tuple,AbstractVector} ? after : (after,) + applications = Symbol[Symbol(value) for value in values] + isempty(applications) && error("Selector keyword `after` cannot be empty.") + return unique!(applications) +end + +function _validate_from_status_selector!( + selector::AbstractObjectMultiplicity, + process_filter, + application_filter, + applications_by_id, + consumer_application_id, +) + order_after = _selector_order_after(selector) + if !_selector_from_status(selector) + isempty(order_after) || error( + "Selector keyword `after` is only supported with `from_status=true`. ", + "Producer-bound inputs already derive their order from the selected application." + ) + return order_after + end + isnothing(process_filter) || error( + "`from_status=true` cannot be combined with `process=` because it deliberately ", + "reads the selected objects' current Status without choosing a producer application." + ) + isnothing(application_filter) || error( + "`from_status=true` cannot be combined with `application=` because it deliberately ", + "reads the selected objects' current Status without choosing a producer application." + ) + _selector_has_policy(selector) && error( + "`from_status=true` cannot be combined with a temporal `policy=`. Remove ", + "`from_status=true` when reading a producer stream." + ) + isnothing(_selector_window(selector)) || error( + "`from_status=true` cannot be combined with `window=`. Status bindings are ", + "same-step live references, not temporal streams." + ) + for application_id in order_after + application_id == consumer_application_id && error( + "Status input on application `$(consumer_application_id)` cannot be ordered after itself." + ) + haskey(applications_by_id, application_id) || error( + "Status input on application `$(consumer_application_id)` requested ", + "`after=$(repr(application_id))`, but no application with that id exists." + ) + end + return order_after +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[] + selector isa Many && sizehint!(refs, length(source_ids) + 1) + 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 _has_stream_only_input_source( + source_application_ids, + source_var::Symbol, + applications_by_id, +) + return any(source_application_ids) do application_id + application = applications_by_id[application_id] + _publish_mode_for_output(application.spec, source_var) == + :stream_only + end +end + +function _stream_only_initial_reference( + model::CompositeModel, + source_id::ObjectId, + source_application_ids, + source_var::Symbol, + applications_by_id, +) + matching_applications = CompiledModelApplication[ + applications_by_id[application_id] + for application_id in source_application_ids + if source_id in applications_by_id[application_id].target_ids + ] + if length(matching_applications) == 1 + application = only(matching_applications) + if _publish_mode_for_output(application.spec, source_var) == + :stream_only + return Ref( + _private_initial_value( + getproperty(outputs_(application.spec), source_var), + ), + ) + end + end + return _status_ref_or_nothing( + _model_object(model, source_id).status, + source_var, + ) +end + +function _stream_only_initial_carrier( + model::CompositeModel, + selector::AbstractObjectMultiplicity, + source_ids, + source_application_ids, + source_var::Symbol, + applications_by_id, +) + references = Base.RefValue[] + for source_id in source_ids + reference = _stream_only_initial_reference( + model, + source_id, + source_application_ids, + source_var, + applications_by_id, + ) + isnothing(reference) && return nothing + push!(references, reference) + end + if selector isa Many + isempty(references) && return RefVector{Any}() + return _ref_vector_carrier(references) + end + return isempty(references) ? nothing : only(references) +end + +function _ref_vector_carrier(refs) + T = typeof(refs[1][]) + typed_refs = Base.RefValue{T}[] + sizehint!(typed_refs, length(refs) + 1) + 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 = outputs_(application.spec) + for object_id in application.target_ids + status = _ensure_model_object_status!(model, object_id) + for (variable, value) in pairs(defaults) + _publish_mode_for_output(application.spec, variable) == + :canonical || continue + status = _status_with_default( + status, + variable, + _private_initial_value(value), + ) + end + _model_object(model, object_id).status = status + end + end + return model +end + +function _prepare_model_input_defaults!(model::CompositeModel, applications) + for application in applications + schema = _input_schema(application.spec) + defaults = _input_default_values(schema) + for object_id in application.target_ids + status = _ensure_model_object_status!(model, object_id) + for (variable, value) in pairs(defaults) + variable = Symbol(variable) + variable in propertynames(status) && continue + status = _status_with_default( + status, + variable, + _private_initial_value(value), + ) + push!( + get!( + model.input_default_status_variables, + object_id, + Set{Symbol}(), + ), + variable, + ) + end + _model_object(model, object_id).status = status + end + 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) + delete!( + get!( + model.input_default_status_variables, + binding.consumer_id, + Set{Symbol}(), + ), + binding.input, + ) + end + return model +end + +function _private_temporal_value(value) + value isa AbstractArray && return copy(value) + return value +end + +function _temporal_input_initial(binding::CompiledModelInputBinding, status::Status) + initial = _input_value(binding.carrier) + if isnothing(initial) + binding.input in propertynames(status) || error( + "Temporal input `$(binding.input)` on application ", + "`$(binding.application_id)` has neither a resolved source carrier nor an ", + "initialized status value. Resolved source objects: ", + "`$(Tuple(id.value for id in binding.source_ids))`.", + ) + initial = status[binding.input] + end + if binding.multiplicity == :many + initial isa AbstractVector || error( + "Temporal `Many` input `$(binding.input)` on application ", + "`$(binding.application_id)` requires a vector-like initialized value; got ", + "`$(typeof(initial))`.", + ) + if length(initial) == length(binding.source_ids) + return Any[_private_temporal_value(value) for value in initial] + end + binding.policy isa PreviousTimeStep && error( + "Temporal `Many` input `$(binding.input)` on application ", + "`$(binding.application_id)` resolved $(length(binding.source_ids)) source ", + "objects but has $(length(initial)) initialized values.", + ) + isempty(binding.source_ids) && return Any[] + isempty(initial) && error( + "Temporal `Many` input `$(binding.input)` on application ", + "`$(binding.application_id)` needs an initialized element to preallocate ", + "$(length(binding.source_ids)) temporal values.", + ) + return Any[ + _private_temporal_value(first(initial)) + for _ in binding.source_ids + ] + end + return _private_temporal_value(initial) +end + +function _private_temporal_storage(binding::CompiledModelInputBinding, initial) + binding.multiplicity == :many || + return _private_temporal_value(initial) + isempty(initial) && return RefVector{Any}() + references = Base.RefValue[Ref(_private_temporal_value(value)) for value in initial] + return _ref_vector_carrier(references) +end + +function _temporal_source_application( + binding::CompiledModelInputBinding, + source_id::ObjectId, + applications_by_id, + application_positions, +) + isempty(binding.source_application_ids) && return nothing + 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 applications_by_id[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)`.", + ) + elseif binding.policy isa PreviousTimeStep + return last(sort!(matches; by=application_id -> application_positions[application_id])) + end + error( + "Temporal model input `$(binding.input)` from ", + "`$(source_id.value).$(binding.source_var)` has ambiguous source ", + "applications `$(matches)`. Add `application=...` to the `inputs=...` selector.", + ) +end + +function _validate_temporal_input_output_overlap!( + application::CompiledModelApplication, + temporal_bindings, +) + output_names = Set(Symbol.(keys(outputs_(application.spec)))) + for binding in temporal_bindings + binding.input in output_names || continue + error( + "Application `$(application.id)` declares `$(binding.input)` as both a ", + "temporal input and an output. A temporal input uses application-local ", + "storage while outputs publish through canonical status, so this overlap ", + "is ambiguous. Use distinct names such as `previous_$(binding.input)` for ", + "the lagged input and `$(binding.input)` for the current output, then map ", + "the source explicitly.", + ) + end + return nothing +end + +function _compile_model_status_view( + model::CompositeModel, + application::CompiledModelApplication, + object_id::ObjectId, + input_bindings, + applications_by_id, + application_positions, +) + canonical_status = _ensure_model_object_status!(model, object_id) + temporal_bindings = Tuple( + binding for binding in input_bindings + if binding.carrier_hint == :temporal_stream + ) + _validate_temporal_input_output_overlap!(application, temporal_bindings) + temporal_inputs = Tuple(begin + initial = _temporal_input_initial(binding, canonical_status) + CompiledTemporalInput( + binding, + Union{Nothing,Symbol}[ + _temporal_source_application( + binding, + source_id, + applications_by_id, + application_positions, + ) + for source_id in binding.source_ids + ], + initial, + Ref(_private_temporal_storage(binding, initial)), + ) + end for binding in temporal_bindings) + temporal_by_name = Dict( + temporal_input.binding.input => temporal_input + for temporal_input in temporal_inputs + ) + output_defaults = outputs_(application.spec) + private_output_names = Tuple( + Symbol(variable) for variable in keys(output_defaults) + if _publish_mode_for_output(application.spec, variable) == + :stream_only + ) + private_outputs = NamedTuple{private_output_names}( + Tuple( + Ref(_private_initial_value(getproperty(output_defaults, name))) + for name in private_output_names + ), + ) + canonical_names = propertynames(canonical_status) + private_names = Tuple( + name for name in private_output_names + if !(name in canonical_names) + ) + temporal_names = Tuple( + input.binding.input + for input in temporal_inputs + if !(input.binding.input in canonical_names) && + !(input.binding.input in private_output_names) + ) + names = (canonical_names..., private_names..., temporal_names...) + references = ntuple(length(names)) do index + name = names[index] + temporal_input = get(temporal_by_name, name, nothing) + isnothing(temporal_input) || return temporal_input.reference + hasproperty(private_outputs, name) && + return getproperty(private_outputs, name) + return refvalue(canonical_status, name) + end + status = Status(NamedTuple{names}(references)) + return CompiledModelStatusView( + status, + canonical_status, + temporal_inputs, + private_outputs, + ) +end + +function _compile_model_status_views( + model::CompositeModel, + applications, + applications_by_id, + input_bindings_by_target, + application_order, +) + views = Dict{Tuple{Symbol,ObjectId},Any}() + positions = Dict( + application_id => index + for (index, application_id) in pairs(application_order) + ) + for application in applications + for object_id in application.target_ids + key = (application.id, object_id) + views[key] = _compile_model_status_view( + model, + application, + object_id, + get(input_bindings_by_target, key, ()), + applications_by_id, + positions, + ) + end + end + return views +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 _potential_input_source_applications( + applications_by_id, + source_var::Symbol, + process_filter, + application_filter, +) + matches = Symbol[ + application.id + for application in values(applications_by_id) + if source_var in _model_output_names(application) && + (isnothing(process_filter) || application.process == process_filter) && + (isnothing(application_filter) || application.id == application_filter) + ] + sort!(matches) + 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." + ) + _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 + selector isa Many && sizehint!(source_ids, length(source_ids) + 1) + window = _selector_window(selector) + source_var = _selector_var(selector, input_sym) + process_filter = _criteria_get(criteria(selector), :process, nothing) + application_filter = _selector_application(selector) + order_after_application_ids = _validate_from_status_selector!( + selector, + process_filter, + application_filter, + applications_by_id, + application.id, + ) + source_application_ids = if _selector_from_status(selector) + Symbol[] + else + _matching_input_source_applications( + applications_by_object, + source_ids, + source_var, + process_filter, + application_filter, + allow_empty=selector isa OptionalOne || + (selector isa Many && isempty(source_ids)), + ) + end + if selector isa Many && + isempty(source_ids) && + (!isnothing(process_filter) || !isnothing(application_filter)) + source_application_ids = _potential_input_source_applications( + applications_by_id, + source_var, + process_filter, + application_filter, + ) + isempty(source_application_ids) && 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 + 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 the `inputs=...` selector.", + ) + 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 + stream_only_source = _has_stream_only_input_source( + source_application_ids, + source_var, + applications_by_id, + ) + carrier = if stream_only_source + _stream_only_initial_carrier( + model, + selector, + source_ids, + source_application_ids, + source_var, + applications_by_id, + ) + else + _input_carrier(model, selector, source_ids, source_var) + end + carrier_hint = if isempty(source_ids) && selector isa OptionalOne + :optional_default + elseif stream_only_source + :temporal_stream + else + _carrier_hint(selector, policy, window) + end + _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, + order_after_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(_input_schema(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, +) + !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 + binding.policy isa PreviousTimeStep && continue + binding.carrier_hint == :optional_default && + isnothing(binding.carrier) && + continue + 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 + schema = _input_schema(application.spec) + for object_id in application.target_ids + for (input_, declaration) in pairs(schema) + declaration isa Required || continue + input = Symbol(input_) + (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 a `ModelSpec(...; 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." + ) + 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 + ordering_sources = ( + binding.source_application_ids..., + binding.order_after_application_ids..., + ) + for source_id in ordering_sources + 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) + any(application -> !isempty(updates(application.spec)), applications) || + return children + 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_children( + applications, + input_bindings, + call_owners, +) + children = Dict{Symbol,Set{Symbol}}() + _model_input_order_edges!(children, input_bindings, call_owners) + _model_update_order_edges!(children, applications) + return children +end + +function _compile_model_application_order(applications, input_bindings, call_bindings) + children = _compile_model_application_children( + applications, + input_bindings, + _model_call_owners(call_bindings), + ) + 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(_input_schema(application.spec)))), + outputs=Tuple(Symbol.(keys(outputs_(application.spec)))), + environment_inputs=Tuple(Symbol.(keys(environment_inputs_(application.spec)))), + environment_outputs=Tuple(Symbol.(keys(environment_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, + order_after_application_ids=binding.order_after_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_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..62513b4cc --- /dev/null +++ b/src/composite_model/environment_bindings.jl @@ -0,0 +1,500 @@ +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 _object_environment_context(application::CompiledModelApplication, object::Object) + scale = isnothing(object.scale) ? :Default : object.scale + return EnvironmentContext(application.id, object.id, scale, application.process) +end + +""" + bind_environment(backend, object, context, config=nothing) + +Compile and return an opaque backend-specific handle for one model target. +`context` contains immutable application/object metadata and `config` is the +payload declared with `Environment(...)`. Spatial backends should resolve the +object's geometry here and store the resulting cell, voxel, layer, provider, or +commit sink in a concrete handle. Runtime sampling and commits receive that +handle directly. +""" +bind_environment(backend, object::Object, context, config=nothing) = nothing + +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_entity(object::Object) + 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), + ) +end + +function _model_environment_entities(model::CompositeModel, object_ids=nothing) + objects = if isnothing(object_ids) + model_objects(model) + else + ( + _model_object(model, object_id) + for object_id in object_ids + if haskey(model.registry.objects, object_id) + ) + end + return [_model_environment_entity(object) for object in objects] +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, + dirty_object_ids=nothing, +) + backends = _model_environment_backends(model, compiled) + filter!(backend -> !(backend isa GlobalConstant), backends) + isempty(backends) && return nothing + entities = _model_environment_entities(model, dirty_object_ids) + removed_object_ids = isnothing(dirty_object_ids) ? + ObjectId[] : + ObjectId[ + object_id for object_id in dirty_object_ids + if !haskey(model.registry.objects, object_id) + ] + for backend in backends + update_index!(backend, entities, removed_object_ids) + 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) + required_inputs = _environment_variable_names(environment_inputs_(application.spec)) + source_inputs = _environment_source_variable_names(application.spec) + produced_outputs = _environment_variable_names(environment_outputs_(application.spec)) + for object_id in application.target_ids + object = _model_object(model, object_id) + context = _object_environment_context(application, object) + binding_object, geometry_source_object_id, geometry_source = + _environment_binding_object(model, object) + handle = bind_environment( + backend, + binding_object, + context, + _environment_config_payload(config), + ) + push!( + bindings, + CompiledEnvironmentBinding( + application.id, + object_id, + backend, + handle, + required_inputs, + source_inputs, + produced_outputs, + context, + 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 source 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 + source = environment_source(binding.backend) + source_index = findfirst(entry -> entry[1] === source, prepared_sources) + sampler = if isnothing(source_index) + prepared = _prepare_environment_sampler(source) + push!(prepared_sources, (source, prepared)) + prepared + else + prepared_sources[source_index][2] + end + samplers_by_application[binding.application_id] = sampler + end + return samplers_by_application +end + +struct PreparedGlobalEnvironmentRows{R} + rows::R +end + +# A `GlobalConstant` table is immutable simulation input. Materialize +# heterogeneous DataFrame rows once so model kernels receive concrete +# `NamedTuple` rows instead of type-erased `DataFrameRow` property values. +_prepare_global_environment(source::DataFrames.AbstractDataFrame) = + PreparedGlobalEnvironmentRows(Tables.rowtable(source)) +_prepare_global_environment(source) = source + +function _prepared_global_environment(bindings, cache=IdDict{Any,Any}()) + for binding in bindings + binding.backend isa GlobalConstant || continue + source = environment_source(binding.backend) + haskey(cache, source) && continue + cache[source] = _prepare_global_environment(source) + end + return cache +end + +function _compiled_environment_bindings( + model::CompositeModel, + compiled::CompiledCompositeModel, + bindings, + by_target, + samplers_by_application=_model_environment_samplers(bindings), + prepared_global_environment=_prepared_global_environment(bindings), + sample_cache=Dict{Tuple{Symbol,Int},Any}(), + positions_by_target=Dict( + (binding.application_id, binding.object_id) => index + for (index, binding) in pairs(bindings) + ), +) + return CompiledEnvironmentBindings( + model, + bindings, + by_target, + positions_by_target, + samplers_by_application, + prepared_global_environment, + sample_cache, + model.revision, + model.environment_revision, + objectid(compiled.applications), + ) +end + +function _index_environment_bindings(bindings) + 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 by_target +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 = _index_environment_bindings(bindings) + return _compiled_environment_bindings(model, compiled, bindings, by_target) +end + +function _same_environment_backend(a, b) + a === b && return true + if a isa GlobalConstant && b isa GlobalConstant + return environment_source(a) === environment_source(b) + end + return false +end + +function _same_environment_context(a, b) + return a.application == b.application && + a.object_id == b.object_id && + a.scale == b.scale && + a.process == b.process +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) + required_inputs = _environment_variable_names(environment_inputs_(application.spec)) + source_inputs = _environment_source_variable_names(application.spec) + produced_outputs = _environment_variable_names(environment_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) + context = _object_environment_context(application, object) + _, geometry_source_object_id, geometry_source = + _environment_binding_object(model, object) + _same_environment_backend(old.backend, backend) || 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_context(old.context, context) || 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, + backend, + old.handle, + required_inputs, + source_inputs, + produced_outputs, + context, + geometry_source_object_id, + geometry_source, + config, + ), + ) + end + end + end + changed || return cached + _validate_model_environment_inputs!(bindings, compiled.applications_by_id) + by_target = _index_environment_bindings(bindings) + return _compiled_environment_bindings(model, compiled, bindings, by_target) +end + +function _refresh_environment_bindings_for_objects( + model::CompositeModel, + compiled::CompiledCompositeModel, + cached::CompiledEnvironmentBindings, + dirty_object_ids, +) + dirty_ids = ObjectId[dirty_object_ids...] + _sort_object_ids!(dirty_ids) + _update_model_environment_indices!(model, compiled, dirty_ids) + stale_targets = Set{Tuple{Symbol,ObjectId}}() + replacements = Dict{Tuple{Symbol,ObjectId},CompiledEnvironmentBinding}() + for object_id in dirty_ids + current_applications = get( + compiled.applications_by_object, + object_id, + (), + ) + current_application_ids = Set( + application.id for application in current_applications + ) + for application in compiled.applications + target = (application.id, object_id) + haskey(cached.by_target, target) || continue + application.id in current_application_ids || + push!(stale_targets, target) + end + haskey(model.registry.objects, object_id) || continue + for application in current_applications + partial_application = CompiledModelApplication( + application.id, + application.spec, + application.process, + application.name, + ObjectId[object_id], + 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 + end + _validate_model_environment_inputs!( + values(replacements), + compiled.applications_by_id, + ) + + for target in stale_targets + position = pop!(cached.positions_by_target, target) + last_position = lastindex(cached.bindings) + if position != last_position + moved_binding = cached.bindings[last_position] + cached.bindings[position] = moved_binding + cached.positions_by_target[( + moved_binding.application_id, + moved_binding.object_id, + )] = position + end + pop!(cached.bindings) + delete!(cached.by_target, target) + end + for (target, binding) in replacements + position = get(cached.positions_by_target, target, nothing) + if isnothing(position) + push!(cached.bindings, binding) + cached.positions_by_target[target] = + lastindex(cached.bindings) + else + cached.bindings[position] = binding + end + cached.by_target[target] = binding + end + return _compiled_environment_bindings( + model, + compiled, + cached.bindings, + cached.by_target, + cached.samplers_by_application, + cached.prepared_global_environment, + cached.sample_cache, + cached.positions_by_target, + ) +end + +function explain_environment_bindings(compiled::CompiledEnvironmentBindings) + rows = [ + ( + application_id=binding.application_id, + object_id=binding.object_id.value, + backend_type=isnothing(binding.backend) ? nothing : typeof(binding.backend), + handle=binding.handle, + 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, + context=binding.context, + config=binding.config, + ) + for binding in compiled.bindings + ] + sort!( + rows; + by=row -> (string(row.application_id), string(row.object_id)), + ) + return rows +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..cc1722c82 --- /dev/null +++ b/src/composite_model/registry_topology.jl @@ -0,0 +1,1300 @@ +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 + +""" + ObjectId(value) + +Stable identity of one [`Object`](@ref) in a [`CompositeModel`](@ref). +Strings are normalized to symbols; an existing `ObjectId` is returned +unchanged. +""" +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 `ModelSpec(...; on=...)` +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, application, model) + +Replace one template model application on one exceptional object. Select the +template application by its application name. The replacement must implement +the same process and variable contract. +""" +struct Override{M<:AbstractModel} + object::ObjectId + application::Symbol + model::M +end + +function Override(; object, application, model::AbstractModel) + return Override( + ObjectId(object), + Symbol(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 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." + ) + 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) +environment_hint(model::ObjectModelOverrides) = environment_hint(model.base) +environment_inputs_(model::ObjectModelOverrides) = environment_inputs_(model.base) +environment_outputs_(model::ObjectModelOverrides) = environment_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} + ancestor_ids_by_object::Dict{ObjectId,Vector{ObjectId}} +end + +ObjectRegistry() = ObjectRegistry( + Dict{ObjectId,Any}(), + Dict{Symbol,Set{ObjectId}}(), + Dict{Symbol,Set{ObjectId}}(), + Dict{Symbol,Set{ObjectId}}(), + Dict{Symbol,ObjectId}(), + Dict{ObjectId,Vector{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}} + binding_dirty_kind::Symbol + input_default_status_variables::Dict{ObjectId,Set{Symbol}} + 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}(), + :full, + Dict{ObjectId,Set{Symbol}}(), + 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=nothing, + 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=nothing, + 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 + ModelSpec(application_model; on=selector, every=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; + kind::Symbol=:full, +) + kind in (:addition, :structural, :full) || error( + "Unsupported binding dirty kind `$(kind)`.", + ) + if isnothing(object_id) + model.binding_cache = nothing + model.binding_dirty_objects = nothing + model.binding_dirty_kind = :full + elseif !isnothing(model.binding_dirty_objects) + push!(model.binding_dirty_objects, object_id) + model.binding_dirty_kind = if model.binding_dirty_kind == :full + :full + elseif model.binding_dirty_kind == :clean + kind + elseif model.binding_dirty_kind == kind + kind + else + :structural + end + 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 _object_ancestor_ids( + registry::ObjectRegistry, + object_id::ObjectId, +) + ancestors = get(registry.ancestor_ids_by_object, object_id, nothing) + isnothing(ancestors) && error( + "No cached topology path for model object `$(object_id.value)`.", + ) + return ancestors +end + +function _refresh_object_ancestor_ids!( + model::CompositeModel, + object_id::ObjectId, + parent_ancestors::Vector{ObjectId}, +) + ancestors = copy(parent_ancestors) + push!(ancestors, object_id) + model.registry.ancestor_ids_by_object[object_id] = ancestors + object = _model_object(model, object_id) + for child_id in object.children + _refresh_object_ancestor_ids!( + model, + child_id, + ancestors, + ) + end + return nothing +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) + parent_ancestors = isnothing(parent_id) ? + ObjectId[] : + _object_ancestor_ids(registry, parent_id) + ancestors = copy(parent_ancestors) + push!(ancestors, object.id) + registry.ancestor_ids_by_object[object.id] = ancestors + 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; kind=:addition) + 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) + delete!(model.registry.ancestor_ids_by_object, object.id) + delete!(model.input_default_status_variables, object.id) + _mark_bindings_dirty!(model, object.id; kind=:structural) + 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 + parent_ancestors = isnothing(new_parent_id) ? + ObjectId[] : + _object_ancestor_ids(model.registry, new_parent_id) + _refresh_object_ancestor_ids!( + model, + object.id, + parent_ancestors, + ) + for dirty_id in _descendant_ids(model, object.id) + _mark_bindings_dirty!(model, dirty_id; kind=:structural) + end + 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, + performance=nothing, +) + uses_model_applications = specs === model.applications + if !uses_model_applications + return compile_composite_model( + model, + specs; + performance=performance, + ) + 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) + if can_extend && model.binding_dirty_kind == :addition + model.binding_cache = _extend_compiled_scene( + model, + model.binding_cache, + model.binding_dirty_objects, + ; + performance=performance, + ) + elseif can_extend && model.binding_dirty_kind == :structural + delta = _prepare_structural_compiled_delta( + model, + model.binding_cache, + model.binding_dirty_objects, + performance, + ) + model.binding_cache = _extend_compiled_scene( + model, + delta.compiled, + model.binding_dirty_objects; + forced_input_binding_keys=delta.forced_input_binding_keys, + forced_call_target_keys=delta.forced_call_target_keys, + previous_temporal_sources_seed=delta.previous_temporal_sources, + changed_application_ids_seed=delta.changed_application_ids, + changed_target_ids_seed=delta.changed_target_ids, + application_graph_may_shrink=delta.graph_may_shrink, + recompute_call_owners=delta.call_graph_may_shrink, + pure_addition=false, + structural_dirty_ids=model.binding_dirty_objects, + performance=performance, + ) + _remove_stale_status_views!( + model.binding_cache, + delta.changed_target_ids, + ) + else + model.binding_cache = + compile_composite_model( + model, + model.applications; + performance=performance, + ) + end + model.bindings_dirty = false + model.binding_dirty_objects = Set{ObjectId}() + model.binding_dirty_kind = :clean + 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}() + elseif model.environment_binding_cache.model_revision == compiled.revision && + model.environment_binding_cache.environment_revision == + model.environment_revision && + model.environment_binding_cache.applications_identity == + objectid(compiled.applications) + return model.environment_binding_cache + 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, + 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..3f149f46f --- /dev/null +++ b/src/composite_model/runtime_outputs.jl @@ -0,0 +1,5275 @@ +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}} + dependency_outputs_by_application::Dict{Symbol,Vector{Symbol}} + historical_outputs_by_application::Dict{Symbol,Vector{Symbol}} +end + +""" + TemporalDependencyBuffer{T} <: AbstractVector{Tuple{Float64,T}} + +Typed, bounded storage for an output retained only to satisfy temporal model +dependencies. Requested and `outputs=:all` streams continue to use append-only +vectors. The logical indexing order is oldest to newest, independently of the +physical circular-buffer layout. +""" +mutable struct TemporalDependencyBuffer{T} <: AbstractVector{Tuple{Float64,T}} + times::Vector{Float64} + values::Vector{T} + first_slot::Int + sample_count::Int +end + +function TemporalDependencyBuffer{T}(capacity::Integer) where {T} + capacity > 0 || throw( + ArgumentError("Temporal dependency capacity must be positive."), + ) + return TemporalDependencyBuffer{T}( + Vector{Float64}(undef, capacity), + Vector{T}(undef, capacity), + 1, + 0, + ) +end + +Base.IndexStyle(::Type{<:TemporalDependencyBuffer}) = IndexLinear() +Base.size(buffer::TemporalDependencyBuffer) = (buffer.sample_count,) + +@inline function _temporal_dependency_slot( + buffer::TemporalDependencyBuffer, + index::Int, +) + @boundscheck checkbounds(buffer, index) + return mod1(buffer.first_slot + index - 1, length(buffer.times)) +end + +@inline function Base.getindex(buffer::TemporalDependencyBuffer, index::Int) + slot = _temporal_dependency_slot(buffer, index) + return (@inbounds buffer.times[slot], @inbounds buffer.values[slot]) +end + +@inline function Base.setindex!( + buffer::TemporalDependencyBuffer{T}, + sample::Tuple{Float64,T}, + index::Int, +) where {T} + slot = _temporal_dependency_slot(buffer, index) + @inbounds buffer.times[slot] = first(sample) + @inbounds buffer.values[slot] = last(sample) + return sample +end + +@inline function Base.push!( + buffer::TemporalDependencyBuffer{T}, + sample::Tuple{Float64,T}, +) where {T} + capacity = length(buffer.times) + if buffer.sample_count < capacity + slot = mod1(buffer.first_slot + buffer.sample_count, capacity) + buffer.sample_count += 1 + else + slot = buffer.first_slot + buffer.first_slot = mod1(buffer.first_slot + 1, capacity) + end + @inbounds buffer.times[slot] = first(sample) + @inbounds buffer.values[slot] = last(sample) + return buffer +end + +@inline function Base.pop!(buffer::TemporalDependencyBuffer) + isempty(buffer) && throw(ArgumentError("array must be non-empty")) + sample = buffer[end] + buffer.sample_count -= 1 + buffer.sample_count == 0 && (buffer.first_slot = 1) + return sample +end + +@inline function _temporal_dependency_popfirst!( + buffer::TemporalDependencyBuffer, +) + isempty(buffer) && throw(ArgumentError("array must be non-empty")) + sample = buffer[1] + buffer.first_slot = mod1(buffer.first_slot + 1, length(buffer.times)) + buffer.sample_count -= 1 + buffer.sample_count == 0 && (buffer.first_slot = 1) + return sample +end + +""" + RuntimeTemporalInput + +Per-simulation temporal input state compiled into an execution target. It +shares direct references to the producer streams with every compatible +consumer, avoiding a global stream-dictionary lookup in the per-target loop. +""" +struct RuntimeTemporalInput{C,S} + compiled::C + source_streams::S +end + +struct RuntimeOutputStream{V,S,R} + stream::S + reference::R + dependency_horizon::Float64 +end + +_runtime_output_variable(::RuntimeOutputStream{V}) where {V} = V + +""" + RuntimePerformanceCounters + +Opt-in coarse runtime instrumentation used by the performance regression +suite. Pass `performance=true` to [`run!`](@ref), then inspect a copy of the +recorded counters with [`runtime_performance`](@ref). + +The disabled path stores `nothing` in the simulation and does not call +`time_ns()`. Counters deliberately cover compiler/runtime boundaries rather +than individual scientific kernels so instrumentation does not change kernel +dispatch or allocation behavior. +""" +mutable struct RuntimePerformanceCounters + counts::Dict{Symbol,Int} + elapsed_ns::Dict{Symbol,UInt64} +end + +RuntimePerformanceCounters() = + RuntimePerformanceCounters(Dict{Symbol,Int}(), Dict{Symbol,UInt64}()) + +@inline _runtime_performance_start(::Nothing) = UInt64(0) +@inline _runtime_performance_start(::RuntimePerformanceCounters) = time_ns() + +@inline _runtime_performance_finish!(::Nothing, ::Symbol, ::UInt64) = nothing + +@inline function _runtime_performance_finish!( + counters::RuntimePerformanceCounters, + name::Symbol, + started_at::UInt64, +) + elapsed = time_ns() - started_at + counters.elapsed_ns[name] = get(counters.elapsed_ns, name, UInt64(0)) + elapsed + return nothing +end + +@inline _runtime_performance_count!(::Nothing, ::Symbol, amount::Int=1) = nothing + +@inline function _runtime_performance_count!( + counters::RuntimePerformanceCounters, + name::Symbol, + amount::Int=1, +) + counters.counts[name] = get(counters.counts, name, 0) + amount + return nothing +end + +function _same_compiled_binding_tuple(previous, current) + length(previous) == length(current) || return false + return all( + previous[index] === current[index] + for index in eachindex(previous) + ) +end + +function _changed_compiled_binding_target_count(previous, current) + return count(pairs(current)) do (key, bindings) + previous_bindings = get(previous, key, nothing) + isnothing(previous_bindings) || + !_same_compiled_binding_tuple(previous_bindings, bindings) + end +end + +function _removed_compiled_binding_target_count(previous, current) + return count(key -> !haskey(current, key), keys(previous)) +end + +function _changed_environment_binding_count(previous, current) + return count(pairs(current)) do (key, binding) + get(previous, key, nothing) !== binding + end +end + +function _removed_environment_binding_count(previous, current) + return count(key -> !haskey(current, key), keys(previous)) +end + +struct NoEnvironmentOverride end +const _NO_ENVIRONMENT_OVERRIDE = NoEnvironmentOverride() + +""" + RunContext + +Runtime context passed as the final argument to model kernels. Use +[`runtime_model`](@ref), [`call_targets`](@ref), and [`run_call!`](@ref) +instead of inspecting its fields. +""" +mutable struct RunContext{CS,A,CT,TS,OR,C,E} + compiled::CS + environment_bindings::CompiledEnvironmentBindings + application::A + object_id::ObjectId + calls::CT + temporal_streams::TS + output_retention::OR + time::Float64 + constants::C + publication_allowed::Bool + environment::E + targeted_topology_runtime::Any +end + +function RunContext( + compiled, + environment_bindings, + application, + object_id, + calls, + temporal_streams, + output_retention, + time, + constants, + publication_allowed, + environment, +) + return RunContext( + compiled, + environment_bindings, + application, + object_id, + calls, + temporal_streams, + output_retention, + time, + constants, + publication_allowed, + environment, + nothing, + ) +end + +struct CallTarget{CS,EB,A,M,S,VS,TI,CT,ENV,TS,OR,C,E} + compiled::CS + environment_bindings::EB + application::A + object_id::ObjectId + model::M + status::S + canonical_status::VS + temporal_inputs::TI + calls::CT + environment_binding::ENV + temporal_streams::TS + output_retention::OR + time::Float64 + constants::C + publication_allowed::Bool + environment::E +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,E} <: AbstractVector{CallTarget} + compiled::CS + environment_bindings::EB + binding::B + temporal_streams::TS + output_retention::OR + time::Float64 + constants::C + publication_allowed::Bool + environment::E + execution_targets::Dict{Tuple{Symbol,ObjectId},Any} +end + +function CallTargets( + compiled, + environment_bindings, + binding, + temporal_streams, + output_retention, + time, + constants, + publication_allowed, + environment, +) + return CallTargets( + compiled, + environment_bindings, + binding, + temporal_streams, + output_retention, + time, + constants, + publication_allowed, + environment, + Dict{Tuple{Symbol,ObjectId},Any}(), + ) +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, + environment, +) + target = CallTargets( + compiled, + environment_bindings, + first(bindings), + temporal_streams, + output_retention, + float(time), + constants, + publication_allowed, + environment, + ) + return ( + target, + _runtime_call_targets( + compiled, + environment_bindings, + Base.tail(bindings), + temporal_streams, + output_retention, + time, + constants, + publication_allowed, + environment, + )..., + ) +end + +@inline _prepare_runtime_call_targets!( + ::Tuple{}, + compiled, + environment_bindings, + temporal_streams, + output_retention, + time, + constants, + publication_allowed, + environment, +) = nothing + +@inline function _prepare_runtime_call_targets!( + calls::Tuple, + compiled, + environment_bindings, + temporal_streams, + output_retention, + time, + constants, + publication_allowed, + environment, +) + targets = first(calls) + runtime_changed = + targets.compiled !== compiled || + targets.environment_bindings !== environment_bindings || + targets.temporal_streams !== temporal_streams || + targets.output_retention !== output_retention || + targets.constants !== constants + runtime_changed && empty!(targets.execution_targets) + targets.compiled = compiled + targets.environment_bindings = environment_bindings + targets.temporal_streams = temporal_streams + targets.output_retention = output_retention + targets.time = float(time) + targets.constants = constants + targets.publication_allowed = publication_allowed + targets.environment = environment + _prepare_runtime_call_targets!( + Base.tail(calls), + compiled, + environment_bindings, + temporal_streams, + output_retention, + time, + constants, + publication_allowed, + environment, + ) + return nothing +end + +abstract type AbstractExecutionBatch end +struct UnspecifiedModelEnvironment end +const _UNSPECIFIED_SCENE_ENVIRONMENT = UnspecifiedModelEnvironment() +const _SCENE_RAW_ENVIRONMENT_CACHE_ID = Symbol("#raw_global_environment") + +struct RawGlobalModelEnvironment{M} + sampled_environment::M +end + +struct CachedGlobalModelEnvironment{B} + binding::B +end + +mutable struct CompiledExecutionTarget{M,S,CS,IB,OB,CB,EB,RC} + object_id::ObjectId + model::M + status::S + canonical_status::CS + input_bindings::IB + output_bindings::OB + call_bindings::CB + environment_binding::EB + context::RC +end + +struct CompiledExecutionBatch{A,T<:AbstractVector,MP} <: AbstractExecutionBatch + application::A + targets::T + environment_provider::MP +end + +struct CompiledApplicationExecutionGroup{A,B} + application::A + batches::B +end + +struct CompiledExecutionPlan{G,B} + groups::G + batches::B + model_revision::Int + environment_revision::Int +end + +""" + Simulation + +Result of running a [`CompositeModel`](@ref). Use `outputs`, `collect_outputs`, +[`final_state`](@ref), and `PlantSimEngine.Diagnostics` to inspect it. +""" +mutable struct Simulation{S,CS,EB,EP,OR,TS,R,RT,C,P} + 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 + performance::P +end + +function Base.show(io::IO, simulation::Simulation) + print( + io, + "Simulation(steps=", + simulation.current_step, + ", objects=", + length(simulation.model.registry.objects), + ", applications=", + length(simulation.compiled.applications), + ", retained_streams=", + length(simulation.temporal_streams), + ")", + ) +end + +function Base.show(io::IO, ::MIME"text/plain", simulation::Simulation) + retained_streams = length(simulation.temporal_streams) + println(io, "Simulation") + println(io, " elapsed steps: ", simulation.current_step) + println(io, " objects: ", length(simulation.model.registry.objects)) + println(io, " applications: ", length(simulation.compiled.applications)) + print(io, " retained streams: ", retained_streams) + if iszero(retained_streams) + print( + io, + "\n hint: no output history retained; rerun with ", + "`outputs=:all` or an `OutputRequest`.", + ) + end +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(target::CallTarget) = target.model +runtime_model(simulation::Simulation) = simulation.model +current_step(simulation::Simulation) = simulation.current_step + +outputs(sim::Simulation) = sim.temporal_streams + +@inline function _final_state_snapshot(simulation::Simulation, object_id) + return NamedTuple(_model_object_status(simulation.model, ObjectId(object_id))) +end + +""" + final_state(simulation) + final_state(simulation, object_id) + final_state(simulation, selector; context=nothing) + +Return a `NamedTuple` snapshot of the latest canonical object status. +The no-selector form requires the simulation to contain exactly one object. +`One` returns one snapshot, `OptionalOne` returns one snapshot or `nothing`, +and `Many` returns a dictionary from object ids to snapshots. + +This accessor reports final state, independently of output retention. Use +`collect_outputs` for retained history. +""" +final_state(simulation::Simulation) = final_state(simulation, One()) + +function final_state(simulation::Simulation, object_id) + return _final_state_snapshot(simulation, object_id) +end + +function final_state( + simulation::Simulation, + selector::AbstractObjectMultiplicity; + context=nothing, +) + object_ids = resolve_object_ids( + simulation.model, + selector; + context=context, + ) + if selector isa One + return _final_state_snapshot(simulation, only(object_ids)) + elseif selector isa OptionalOne + return isempty(object_ids) ? + nothing : + _final_state_snapshot(simulation, only(object_ids)) + end + return Dict( + object_id.value => _final_state_snapshot(simulation, object_id) + for object_id in object_ids + ) +end + +""" + runtime_performance(simulation) + +Return a stable snapshot of opt-in runtime performance counters, or `nothing` +when the simulation was not started with `performance=true`. Elapsed values are +reported in seconds while the internal counters retain nanosecond resolution. +""" +function runtime_performance(simulation::Simulation) + counters = simulation.performance + isnothing(counters) && return nothing + return ( + counts=copy(counters.counts), + elapsed_seconds=Dict( + name => Float64(elapsed) / 1.0e9 + for (name, elapsed) in counters.elapsed_ns + ), + ) +end + +# 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_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 + +@inline function _model_status_view_for_application( + compiled::CompiledCompositeModel, + application::CompiledModelApplication, + object_id::ObjectId, +) + key = (application.id, object_id) + haskey(compiled.status_views_by_target, key) || error( + "No compiled status view for application `$(application.id)` on object ", + "`$(object_id.value)`.", + ) + return compiled.status_views_by_target[key] +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_dependency_only_output( + retention::OutputRetentionPlan, + application_id::Symbol, + variable::Symbol, +) + retention.retain_all && return false + key = (application_id, variable) + return key in retention.temporal_dependencies && + !(key in retention.requested_outputs) +end + +_model_dependency_only_output(::Nothing, application_id::Symbol, variable::Symbol) = + false + +function _model_dependency_capacity( + retention::OutputRetentionPlan, + application_id::Symbol, + variable::Symbol, +) + horizon = get(retention.dependency_horizons, (application_id, variable), 0.0) + return max(1, ceil(Int, horizon)) +end + +function _model_new_output_stream( + value, + retention, + application_id::Symbol, + variable::Symbol, +) + if _model_dependency_only_output(retention, application_id, variable) + capacity = _model_dependency_capacity(retention, application_id, variable) + return TemporalDependencyBuffer{typeof(value)}(capacity) + end + return Tuple{Float64,typeof(value)}[] +end + +function _initialize_model_output_streams!( + streams, + compiled::CompiledCompositeModel, + retention::OutputRetentionPlan, + sizehint_steps::Integer=0, +) + for (application_id, variables) in retention.retained_outputs_by_application + application = _compiled_application_by_id(compiled, application_id) + for object_id in application.target_ids + status = _model_status_view_for_application( + compiled, + application, + object_id, + ).status + for variable in variables + key = _model_stream_key(application_id, object_id, variable) + haskey(streams, key) && continue + hasproperty(status, variable) || error( + "Application `$(application_id)` declares retained output ", + "`$(variable)`, but object `$(object_id.value)` status has no ", + "such variable.", + ) + stream = _model_new_output_stream( + getproperty(status, variable), + retention, + application_id, + variable, + ) + if stream isa Vector && sizehint_steps > 0 + sizehint!( + stream, + max( + 1, + ceil( + Int, + float(sizehint_steps) / + float(application.clock.dt), + ), + ), + ) + end + streams[key] = stream + end + end + end + return streams +end + +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 + +function _model_prune_dependency_stream!( + samples::TemporalDependencyBuffer, + retention::OutputRetentionPlan, + application_id::Symbol, + variable::Symbol, + time::Real, +) + horizon = get(retention.dependency_horizons, (application_id, variable), 0.0) + cutoff = horizon <= 0.0 ? float(time) : float(time) - horizon + 1.0 + while !isempty(samples) && first(samples)[1] < cutoff - 1.0e-8 + _temporal_dependency_popfirst!(samples) + end + return samples +end + +_model_prune_dependency_stream!(samples, ::Nothing, application_id, variable, time) = + samples + +function _model_remove_sample_time!( + samples::TemporalDependencyBuffer, + sample_time::Float64, +) + write_index = 1 + original_length = length(samples) + for read_index in 1:original_length + sample = samples[read_index] + isapprox(first(sample), sample_time; atol=1.0e-8, rtol=0.0) && continue + write_index == read_index || (samples[write_index] = sample) + write_index += 1 + end + samples.sample_count = write_index - 1 + samples.sample_count == 0 && (samples.first_slot = 1) + return samples +end + +function _model_remove_sample_time!(samples, sample_time::Float64) + filter!( + sample -> !isapprox(sample[1], sample_time; atol=1.0e-8, rtol=0.0), + samples, + ) + return samples +end + +@inline function _model_publish_sample!(samples, sample_time::Float64, value) + 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 + _model_remove_sample_time!(samples, sample_time) + end + push!(samples, (sample_time, value)) + return samples +end + +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 = _model_new_output_stream( + value, + retention, + application.id, + var, + ) + 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 + _model_publish_sample!(samples, float(time), value) + _model_prune_dependency_stream!( + samples, + retention, + application.id, + var, + time, + ) + end + return nothing +end + +@inline _model_publish_runtime_outputs!(::Tuple{}, time::Real) = nothing + +@inline function _model_publish_runtime_outputs!( + outputs::Tuple, + time::Real, +) + output = first(outputs) + _model_publish_sample!( + output.stream, + float(time), + output.reference[], + ) + if output.stream isa TemporalDependencyBuffer + cutoff = output.dependency_horizon <= 0.0 ? + float(time) : + float(time) - output.dependency_horizon + 1.0 + while !isempty(output.stream) && + first(output.stream)[1] < cutoff - 1.0e-8 + _temporal_dependency_popfirst!(output.stream) + end + end + _model_publish_runtime_outputs!(Base.tail(outputs), time) + 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 + +@inline function _model_latest_sample( + samples::TemporalDependencyBuffer, + time::Real, +) + requested_time = float(time) + capacity = length(samples.times) + for offset in (samples.sample_count - 1):-1:0 + slot = mod1(samples.first_slot + offset, capacity) + sample_time = @inbounds samples.times[slot] + sample_time <= requested_time && + return @inbounds samples.values[slot] + 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) + return _model_temporal_sample_value( + samples, + time, + policy, + t_start, + timeline, + ) +end + +function _model_temporal_sample_value( + samples, + time::Real, + policy, + t_start::Real, + timeline, +) + 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_assign_private_temporal_value!( + temporal_input::CompiledTemporalInput, + value, +) + current = temporal_input.reference[] + if current isa AbstractVector && value isa AbstractVector + if current isa Vector && length(current) != length(value) + resize!(current, length(value)) + end + axes(current) == axes(value) || error( + "Temporal input `$(temporal_input.binding.input)` changed shape from ", + "`$(axes(current))` to ", + "`$(axes(value))`; use a stable vector shape or a `Many(...)` binding.", + ) + copyto!(current, value) + return temporal_input + end + temporal_input.reference[] = value + return temporal_input +end + +function _materialize_model_temporal_input!( + status::Status, + temporal_input::CompiledTemporalInput, + application::CompiledModelApplication, + streams, + time::Real, + timeline, +) + binding = temporal_input.binding + window_steps = _model_input_window_steps(binding, application, timeline) + t_start = float(time) - float(window_steps) + 1.0 + if binding.multiplicity == :many + storage = temporal_input.reference[] + storage isa AbstractVector || error( + "Temporal `Many` input `$(binding.input)` on application ", + "`$(binding.application_id)` has non-vector private storage ", + "`$(typeof(storage))`.", + ) + length(storage) == length(binding.source_ids) || error( + "Temporal `Many` input `$(binding.input)` on application ", + "`$(binding.application_id)` has $(length(storage)) private values for ", + "$(length(binding.source_ids)) resolved source objects. Refresh the ", + "compiled lifecycle bindings before execution.", + ) + for index in eachindex(binding.source_ids) + value = _model_temporal_source_value( + streams, + temporal_input.source_applications[index], + binding.source_ids[index], + binding.source_var, + time, + binding.policy, + t_start, + timeline, + ) + if isnothing(value) + binding.policy isa PreviousTimeStep || error( + "No temporal model value available for input ", + "`$(binding.input)` from ", + "`$(binding.source_ids[index].value).$(binding.source_var)` ", + "at t=$(time).", + ) + value = temporal_input.initial[index] + end + storage[index] = value + end + return status + end + source_id = only(binding.source_ids) + value = _model_temporal_source_value( + streams, + only(temporal_input.source_applications), + source_id, + binding.source_var, + time, + binding.policy, + t_start, + timeline, + ) + if isnothing(value) + binding.policy isa PreviousTimeStep || error( + "No temporal model value available for input `$(binding.input)` from ", + "`$(source_id.value).$(binding.source_var)` at t=$(time).", + ) + value = temporal_input.initial + end + _model_assign_private_temporal_value!(temporal_input, value) + return status +end + +function _materialize_model_temporal_input!( + status::Status, + runtime_input::RuntimeTemporalInput, + application::CompiledModelApplication, + streams, + time::Real, + timeline, +) + temporal_input = runtime_input.compiled + binding = temporal_input.binding + window_steps = _model_input_window_steps(binding, application, timeline) + t_start = float(time) - float(window_steps) + 1.0 + if binding.multiplicity == :many + storage = temporal_input.reference[] + storage isa AbstractVector || error( + "Temporal `Many` input `$(binding.input)` on application ", + "`$(binding.application_id)` has non-vector private storage ", + "`$(typeof(storage))`.", + ) + length(storage) == length(binding.source_ids) || error( + "Temporal `Many` input `$(binding.input)` on application ", + "`$(binding.application_id)` has $(length(storage)) private values for ", + "$(length(binding.source_ids)) resolved source objects. Refresh the ", + "compiled lifecycle bindings before execution.", + ) + for index in eachindex(binding.source_ids) + value = _model_temporal_sample_value( + runtime_input.source_streams[index], + time, + binding.policy, + t_start, + timeline, + ) + if isnothing(value) + binding.policy isa PreviousTimeStep || error( + "No temporal model value available for input ", + "`$(binding.input)` from ", + "`$(binding.source_ids[index].value).$(binding.source_var)` ", + "at t=$(time).", + ) + value = temporal_input.initial[index] + end + storage[index] = value + end + return status + end + source_id = only(binding.source_ids) + value = _model_temporal_sample_value( + runtime_input.source_streams, + time, + binding.policy, + t_start, + timeline, + ) + if isnothing(value) + binding.policy isa PreviousTimeStep || error( + "No temporal model value available for input `$(binding.input)` from ", + "`$(source_id.value).$(binding.source_var)` at t=$(time).", + ) + value = temporal_input.initial + end + _model_assign_private_temporal_value!(temporal_input, value) + return status +end + +function _materialize_model_inputs!( + compiled::CompiledCompositeModel, + application::CompiledModelApplication, + object_id::ObjectId, + streams=nothing, + time::Real=1, +) + isnothing(streams) && + return _model_object_status(compiled.model, object_id) + view = _model_status_view_for_application(compiled, application, object_id) + return _materialize_model_inputs!( + view.status, + view.temporal_inputs, + compiled, + application, + streams, + time, + ) +end + +function _materialize_model_inputs!( + status::Status, + bindings::Tuple, + compiled::CompiledCompositeModel, + application::CompiledModelApplication, + streams=nothing, + time::Real=1, +) + isnothing(streams) && return status + timeline = compiled.timeline + for temporal_input in bindings + _materialize_model_temporal_input!( + status, + temporal_input, + application, + streams, + time, + timeline, + ) + end + return status +end + +function _model_environment_for_model( + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + object_id::ObjectId, + time::Real, + environment=_NO_ENVIRONMENT_OVERRIDE, +) + binding = _environment_binding_for(env_bindings, application.id, object_id) + return _model_environment_for_binding( + env_bindings, + application, + binding, + time, + environment, + ) +end + +function _model_environment_for_binding( + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + binding, + time::Real, + environment=_NO_ENVIRONMENT_OVERRIDE, +) + isnothing(binding) && return nothing + isnothing(binding.backend) && return nothing + if !(environment isa NoEnvironmentOverride) + return sample_environment( + binding.backend, + binding.handle, + environment, + time, + application.spec, + ) + end + 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_ENVIRONMENT_CACHE_ID, step) + raw_row = get!(env_bindings.sample_cache, raw_key) do + _environment_row_at_step(environment_source(binding.backend), step) + end + sampler = get(env_bindings.samplers_by_application, application.id, nothing) + if !isnothing(sampler) + sampled = _sample_environment_for_model( + sampler, + raw_row, + step, + application.clock, + application.spec, + ) + env_bindings.sample_cache[key] = sampled + return sampled + end + bindings = environment_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.handle, + time, + application.spec, + ) + env_bindings.sample_cache[key] = sampled + return sampled + end + return sample_environment( + binding.backend, + binding.handle, + time, + application.spec, + ) +end + +@inline function _prepare_model_execution_context!( + context::RunContext, + compiled, + environment_bindings, + application, + object_id, + temporal_streams, + output_retention, + time, + constants, + publication_allowed::Bool, + environment, +) + runtime_changed = + context.compiled !== compiled || + context.environment_bindings !== environment_bindings + if runtime_changed + context.compiled = compiled + context.environment_bindings = environment_bindings + context.application = application + context.object_id = object_id + context.temporal_streams = temporal_streams + context.output_retention = output_retention + context.constants = constants + context.targeted_topology_runtime = nothing + end + context.time = float(time) + context.publication_allowed = publication_allowed + context.environment = environment + if runtime_changed + _prepare_runtime_call_targets!( + context.calls, + compiled, + environment_bindings, + temporal_streams, + output_retention, + time, + constants, + publication_allowed, + environment, + ) + end + return context +end + +@inline function _prepare_model_execution_context!( + context::RunContext, + compiled, + environment_bindings, + application, + object_id, + temporal_streams, + output_retention, + time, + constants, +) + return _prepare_model_execution_context!( + context, + compiled, + environment_bindings, + application, + object_id, + temporal_streams, + output_retention, + time, + constants, + true, + _NO_ENVIRONMENT_OVERRIDE, + ) +end + +@inline function _prepare_model_execution_context!( + ::Nothing, + compiled, + environment_bindings, + application, + object_id, + temporal_streams, + output_retention, + time, + constants, + publication_allowed::Bool, + environment, +) + call_bindings = get( + compiled.call_bindings_by_target, + (application.id, object_id), + (), + ) + calls = _runtime_call_targets( + compiled, + environment_bindings, + call_bindings, + temporal_streams, + output_retention, + time, + constants, + publication_allowed, + environment, + ) + return RunContext( + compiled, + environment_bindings, + application, + object_id, + calls, + temporal_streams, + output_retention, + float(time), + constants, + publication_allowed, + environment, + ) +end + +@inline function _prepare_model_execution_context!( + context::Nothing, + compiled, + environment_bindings, + application, + object_id, + temporal_streams, + output_retention, + time, + constants, +) + return _prepare_model_execution_context!( + context, + compiled, + environment_bindings, + application, + object_id, + temporal_streams, + output_retention, + time, + constants, + true, + _NO_ENVIRONMENT_OVERRIDE, + ) +end + +@inline function _run_model_application_with_calls!( + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + object_id::ObjectId, + status, + canonical_status, + environment_value, + calls, + time, + constants, + temporal_streams, + output_retention, + publish::Bool, + environment, +) + context = RunContext( + compiled, + env_bindings, + application, + object_id, + calls, + temporal_streams, + output_retention, + float(time), + constants, + publish, + environment, + ) + model = _application_model(application, object_id) + run!(model, status, environment_value, constants, context) + if publish + _model_publish_outputs!( + temporal_streams, + application, + object_id, + status, + time, + output_retention, + ) + end + return status +end + +@inline function _run_model_application!( + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + object_id::ObjectId, + time::Real, + constants, + temporal_streams, + output_retention, + publish::Bool, + sampled_environment, + environment, +) + status_view = _model_status_view_for_application( + compiled, + application, + object_id, + ) + status = _materialize_model_inputs!( + status_view.status, + status_view.temporal_inputs, + compiled, + application, + temporal_streams, + time, + ) + environment_value = sampled_environment isa UnspecifiedModelEnvironment ? + _model_environment_for_model( + env_bindings, + application, + object_id, + time, + environment, + ) : sampled_environment + call_bindings = get( + compiled.call_bindings_by_target, + (application.id, object_id), + (), + ) + if isempty(call_bindings) + return _run_model_application_with_calls!( + compiled, + env_bindings, + application, + object_id, + status, + status_view.canonical_status, + environment_value, + (), + time, + constants, + temporal_streams, + output_retention, + publish, + environment, + ) + end + calls = _runtime_call_targets( + compiled, + env_bindings, + call_bindings, + temporal_streams, + output_retention, + time, + constants, + publish, + environment, + ) + return _run_model_application_with_calls!( + compiled, + env_bindings, + application, + object_id, + status, + status_view.canonical_status, + environment_value, + calls, + time, + constants, + temporal_streams, + output_retention, + publish, + environment, + ) +end + +@inline function _run_model_execution_target_without_calls!( + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + target::CompiledExecutionTarget, + time::Real, + constants, + temporal_streams, + output_retention, + sampled_environment, + publish_outputs::Bool, + retained_outputs, +) + status = isempty(target.input_bindings) ? + target.status : + _materialize_model_inputs!( + target.status, + target.input_bindings, + compiled, + application, + temporal_streams, + time, + ) + environment_value = sampled_environment isa UnspecifiedModelEnvironment ? + _model_environment_for_binding( + env_bindings, + application, + target.environment_binding, + time, + ) : sampled_environment + context = if target.context isa RunContext + _prepare_model_execution_context!( + target.context, + compiled, + env_bindings, + application, + target.object_id, + temporal_streams, + output_retention, + time, + constants, + ) + else + RunContext( + compiled, + env_bindings, + application, + target.object_id, + (), + temporal_streams, + output_retention, + float(time), + constants, + true, + _NO_ENVIRONMENT_OVERRIDE, + ) + end + run!(target.model, status, environment_value, constants, context) + if publish_outputs + isempty(target.output_bindings) || + _model_publish_runtime_outputs!(target.output_bindings, time) + if isnothing(retained_outputs) || !isempty(retained_outputs) + _model_publish_outputs!( + temporal_streams, + application, + target.object_id, + status, + time, + output_retention, + retained_outputs, + ) + end + end + return status +end + +@inline function _run_model_execution_target!( + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + target::CompiledExecutionTarget, + time::Real, + constants, + temporal_streams, + output_retention, + sampled_environment, + publish_outputs::Bool, + retained_outputs, +) + isempty(target.call_bindings) && return _run_model_execution_target_without_calls!( + compiled, + env_bindings, + application, + target, + time, + constants, + temporal_streams, + output_retention, + sampled_environment, + publish_outputs, + retained_outputs, + ) + status = isempty(target.input_bindings) ? + target.status : + _materialize_model_inputs!( + target.status, + target.input_bindings, + compiled, + application, + temporal_streams, + time, + ) + environment_value = sampled_environment isa UnspecifiedModelEnvironment ? + _model_environment_for_binding( + env_bindings, + application, + target.environment_binding, + time, + ) : sampled_environment + context = _prepare_model_execution_context!( + target.context, + compiled, + env_bindings, + application, + target.object_id, + temporal_streams, + output_retention, + time, + constants, + ) + run!(target.model, status, environment_value, constants, context) + if publish_outputs + isempty(target.output_bindings) || + _model_publish_runtime_outputs!(target.output_bindings, time) + if isnothing(retained_outputs) || !isempty(retained_outputs) + _model_publish_outputs!( + temporal_streams, + application, + target.object_id, + status, + time, + output_retention, + retained_outputs, + ) + end + end + return status +end + +@inline _model_execution_batch_environment( + ::Nothing, + env_bindings, + application, + time, +) = nothing + +@inline _model_execution_batch_environment( + ::UnspecifiedModelEnvironment, + env_bindings, + application, + time, +) = _UNSPECIFIED_SCENE_ENVIRONMENT + +@inline function _model_execution_batch_environment( + provider::RawGlobalModelEnvironment, + env_bindings, + application, + time, +) + return _environment_row_at_step( + provider.sampled_environment, + Int(round(time)), + ) +end + +@inline function _model_execution_batch_environment( + provider::RawGlobalModelEnvironment{<:PreparedGlobalEnvironmentRows}, + env_bindings, + application, + time, +) + return @inbounds provider.sampled_environment.rows[Int(round(time))] +end + +@inline function _model_execution_batch_environment( + provider::CachedGlobalModelEnvironment, + env_bindings, + application, + time, +) + return _model_environment_for_binding( + env_bindings, + application, + provider.binding, + time, + ) +end + +@inline function _run_model_execution_batch!( + batch::CompiledExecutionBatch, + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + time::Real, + constants, + temporal_streams, + output_retention, +) + _model_application_should_run(batch.application, time) || return nothing + shared_environment = _model_execution_batch_environment( + batch.environment_provider, + env_bindings, + batch.application, + time, + ) + publish_outputs = _model_retain_application(output_retention, batch.application.id) + retained_outputs = output_retention isa OutputRetentionPlan ? + get( + output_retention.historical_outputs_by_application, + batch.application.id, + (), + ) : nothing + if isempty(first(batch.targets).call_bindings) + if isnothing(temporal_streams) + for target in batch.targets + _run_model_execution_target_without_calls!( + compiled, + env_bindings, + batch.application, + target, + time, + constants, + temporal_streams, + output_retention, + shared_environment, + publish_outputs, + retained_outputs, + ) + end + return nothing + end + for target in batch.targets + status = isempty(target.input_bindings) ? + target.status : + _materialize_model_inputs!( + target.status, + target.input_bindings, + compiled, + batch.application, + temporal_streams, + time, + ) + environment_value = shared_environment isa UnspecifiedModelEnvironment ? + _model_environment_for_binding( + env_bindings, + batch.application, + target.environment_binding, + time, + ) : shared_environment + context = target.context + if context isa RunContext + _prepare_model_execution_context!( + context, + compiled, + env_bindings, + batch.application, + target.object_id, + temporal_streams, + output_retention, + time, + constants, + ) + else + context = _prepare_model_execution_context!( + context, + compiled, + env_bindings, + batch.application, + target.object_id, + temporal_streams, + output_retention, + time, + constants, + ) + end + run!( + target.model, + status, + environment_value, + constants, + context, + ) + if publish_outputs + isempty(target.output_bindings) || + _model_publish_runtime_outputs!( + target.output_bindings, + time, + ) + if isnothing(retained_outputs) || !isempty(retained_outputs) + _model_publish_outputs!( + temporal_streams, + batch.application, + target.object_id, + status, + time, + output_retention, + retained_outputs, + ) + end + end + end + return nothing + end + for target in batch.targets + _run_model_execution_target!( + compiled, + env_bindings, + batch.application, + target, + time, + constants, + temporal_streams, + output_retention, + shared_environment, + publish_outputs, + retained_outputs, + ) + end + return nothing +end + +function _run_model_execution_batch_profiled!( + batch::CompiledExecutionBatch, + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + time::Real, + constants, + temporal_streams, + output_retention, + performance::RuntimePerformanceCounters, +) + _model_application_should_run(batch.application, time) || return nothing + started_at = _runtime_performance_start(performance) + shared_environment = _model_execution_batch_environment( + batch.environment_provider, + env_bindings, + batch.application, + time, + ) + _runtime_performance_finish!( + performance, + :environment_sampling, + started_at, + ) + publish_outputs = + _model_retain_application(output_retention, batch.application.id) + retained_outputs = output_retention isa OutputRetentionPlan ? + get( + output_retention.historical_outputs_by_application, + batch.application.id, + (), + ) : nothing + for target in batch.targets + started_at = _runtime_performance_start(performance) + status = isempty(target.input_bindings) ? + target.status : + _materialize_model_inputs!( + target.status, + target.input_bindings, + compiled, + batch.application, + temporal_streams, + time, + ) + _runtime_performance_finish!( + performance, + :temporal_input_materialization, + started_at, + ) + + started_at = _runtime_performance_start(performance) + environment_value = + shared_environment isa UnspecifiedModelEnvironment ? + _model_environment_for_binding( + env_bindings, + batch.application, + target.environment_binding, + time, + ) : shared_environment + _runtime_performance_finish!( + performance, + :environment_sampling, + started_at, + ) + + context = _prepare_model_execution_context!( + target.context, + compiled, + env_bindings, + batch.application, + target.object_id, + temporal_streams, + output_retention, + time, + constants, + ) + started_at = _runtime_performance_start(performance) + run!( + target.model, + status, + environment_value, + constants, + context, + ) + _runtime_performance_finish!( + performance, + :scientific_kernel_execution, + started_at, + ) + + publish_outputs || continue + started_at = _runtime_performance_start(performance) + isempty(target.output_bindings) || + _model_publish_runtime_outputs!( + target.output_bindings, + time, + ) + if isnothing(retained_outputs) || !isempty(retained_outputs) + _model_publish_outputs!( + temporal_streams, + batch.application, + target.object_id, + status, + time, + output_retention, + retained_outputs, + ) + end + _runtime_performance_finish!( + performance, + :output_publication, + started_at, + ) + end + return nothing +end + +function _run_model_execution_batch!( + batch::CompiledExecutionBatch, + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings; + time::Real=1, + constants=nothing, + temporal_streams=nothing, + output_retention=nothing, +) + return _run_model_execution_batch!( + batch, + compiled, + env_bindings, + time, + constants, + temporal_streams, + output_retention, + ) +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) + isnothing(binding.application) || push!(ids, binding.application) + end + return ids +end + +function _typed_temporal_source_streams(source_streams::Vector{Any}) + isempty(source_streams) && return () + source_type = typeof(first(source_streams)) + all(source -> typeof(source) === source_type, source_streams) || + return source_streams + typed = Vector{source_type}(undef, length(source_streams)) + copyto!(typed, source_streams) + return typed +end + +function _runtime_temporal_source_stream( + streams, + temporal_input::CompiledTemporalInput, + index::Int, +) + application_id = temporal_input.source_applications[index] + isnothing(application_id) && return nothing + binding = temporal_input.binding + key = _model_stream_key( + application_id, + binding.source_ids[index], + binding.source_var, + ) + stream = get(streams, key, nothing) + isnothing(stream) && error( + "No initialized temporal source stream for application ", + "`$(application_id)` on object `$(binding.source_ids[index].value)` ", + "and variable `$(binding.source_var)`.", + ) + return stream +end + +function _runtime_temporal_input( + temporal_input::CompiledTemporalInput, + streams, +) + binding = temporal_input.binding + if binding.multiplicity == :many + source_streams = Any[ + _runtime_temporal_source_stream(streams, temporal_input, index) + for index in eachindex(binding.source_ids) + ] + return RuntimeTemporalInput( + temporal_input, + _typed_temporal_source_streams(source_streams), + ) + end + return RuntimeTemporalInput( + temporal_input, + _runtime_temporal_source_stream(streams, temporal_input, 1), + ) +end + +_runtime_model_temporal_inputs(temporal_inputs::Tuple, ::Nothing) = + temporal_inputs + +function _runtime_model_temporal_inputs(temporal_inputs::Tuple, streams) + return Tuple( + _runtime_temporal_input(temporal_input, streams) + for temporal_input in temporal_inputs + ) +end + +_runtime_model_output_streams( + status, + application, + object_id, + ::Nothing, + ::Nothing, +) = () + +_runtime_model_output_streams( + status, + application, + object_id, + ::Nothing, + ::OutputRetentionPlan, +) = () + +function _runtime_model_output_streams( + status, + application, + object_id, + streams, + output_retention::OutputRetentionPlan, +) + variables = get( + output_retention.dependency_outputs_by_application, + application.id, + (), + ) + return Tuple(begin + key = _model_stream_key(application.id, object_id, variable) + stream = get(streams, key, nothing) + isnothing(stream) && error( + "No initialized retained output stream for application ", + "`$(application.id)` on object `$(object_id.value)` and variable ", + "`$(variable)`.", + ) + reference = refvalue(status, variable) + RuntimeOutputStream{ + variable, + typeof(stream), + typeof(reference), + }( + stream, + reference, + get( + output_retention.dependency_horizons, + (application.id, variable), + 0.0, + ), + ) + end for variable in variables) +end + +function _runtime_model_output_streams( + status, + application, + object_id, + streams, + ::Nothing, +) + return () +end + +function _compiled_model_execution_context( + compiled, + env_bindings, + application, + object_id, + call_bindings, + temporal_streams, + output_retention, + constants, +) + isnothing(temporal_streams) && return nothing + calls = _runtime_call_targets( + compiled, + env_bindings, + call_bindings, + temporal_streams, + output_retention, + 0.0, + constants, + true, + _NO_ENVIRONMENT_OVERRIDE, + ) + return RunContext( + compiled, + env_bindings, + application, + object_id, + calls, + temporal_streams, + output_retention, + 0.0, + constants, + true, + _NO_ENVIRONMENT_OVERRIDE, + ) +end + +function _compiled_model_execution_target( + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + object_id::ObjectId, + temporal_streams=nothing, + output_retention=nothing, + constants=nothing, +) + status_view = _model_status_view_for_application( + compiled, + application, + object_id, + ) + model = _application_model(application, object_id) + call_bindings = get( + compiled.call_bindings_by_target, + (application.id, object_id), + (), + ) + environment_binding = _environment_binding_for( + env_bindings, + application.id, + object_id, + ) + context = _compiled_model_execution_context( + compiled, + env_bindings, + application, + object_id, + call_bindings, + temporal_streams, + output_retention, + constants, + ) + output_bindings = _runtime_model_output_streams( + status_view.status, + application, + object_id, + temporal_streams, + output_retention, + ) + return CompiledExecutionTarget( + object_id, + model, + status_view.status, + status_view.canonical_status, + _runtime_model_temporal_inputs( + status_view.temporal_inputs, + temporal_streams, + ), + output_bindings, + call_bindings, + environment_binding, + context, + ) +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) + sizehint!(typed, length(typed) + 1) + for (destination, source) in enumerate(first_index:last_index) + typed[destination] = targets[source] + end + return typed +end + +function _compiled_model_execution_environment_provider( + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + target::CompiledExecutionTarget, +) + binding = target.environment_binding + isnothing(binding) && return nothing + binding.backend isa GlobalConstant || + return _UNSPECIFIED_SCENE_ENVIRONMENT + + sampler = get( + env_bindings.samplers_by_application, + application.id, + nothing, + ) + bindings = environment_bindings(application.spec) + has_bindings = bindings isa NamedTuple && !isempty(keys(bindings)) + environment_sources = + _environment_source_overrides(application.spec) + if isnothing(sampler) && + !has_bindings && + isempty(keys(environment_sources)) + source = environment_source(binding.backend) + prepared = get( + env_bindings.prepared_global_environment, + source, + source, + ) + return RawGlobalModelEnvironment(prepared) + end + return CachedGlobalModelEnvironment(binding) +end + +function _append_model_execution_batches!( + batches, + env_bindings::CompiledEnvironmentBindings, + 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), + _compiled_model_execution_environment_provider( + env_bindings, + application, + targets[first_index], + ), + ), + ) + first_index = index + target_type = typeof(targets[index]) + end + push!( + batches, + CompiledExecutionBatch( + application, + _typed_model_execution_targets(targets, first_index, lastindex(targets)), + _compiled_model_execution_environment_provider( + env_bindings, + application, + targets[first_index], + ), + ), + ) + return batches +end + +function compile_model_execution_plan( + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + temporal_streams=nothing, + output_retention=nothing, + constants=nothing, +) + manual_application_ids = _manual_call_application_ids(compiled) + groups = CompiledApplicationExecutionGroup[] + batches = AbstractExecutionBatch[] + for application in _ordered_model_applications(compiled) + application.id in manual_application_ids && continue + first_batch = length(batches) + 1 + targets = Any[ + _compiled_model_execution_target( + compiled, + env_bindings, + application, + object_id, + temporal_streams, + output_retention, + constants, + ) + for object_id in application.target_ids + ] + _append_model_execution_batches!( + batches, + env_bindings, + application, + targets, + ) + first_batch > length(batches) && continue + push!( + groups, + CompiledApplicationExecutionGroup( + application, + batches[first_batch:end], + ), + ) + end + return CompiledExecutionPlan( + groups, + batches, + compiled.revision, + env_bindings.environment_revision, + ) +end + +function _model_execution_inputs_match( + runtime_inputs::Tuple, + compiled_inputs::Tuple, +) + length(runtime_inputs) == length(compiled_inputs) || return false + for index in eachindex(runtime_inputs) + runtime_input = runtime_inputs[index] + compiled_input = compiled_inputs[index] + if runtime_input isa RuntimeTemporalInput + runtime_input.compiled === compiled_input || return false + else + runtime_input === compiled_input || return false + end + end + return true +end + +function _model_execution_outputs_match( + runtime_outputs::Tuple, + application::CompiledModelApplication, + output_retention, +) + variables = output_retention isa OutputRetentionPlan ? + get( + output_retention.dependency_outputs_by_application, + application.id, + (), + ) : () + length(runtime_outputs) == length(variables) || return false + for index in eachindex(runtime_outputs) + _runtime_output_variable(runtime_outputs[index]) == variables[index] || + return false + end + return true +end + +function _model_execution_target_change_reason( + target::CompiledExecutionTarget, + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + output_retention=nothing, +) + object_id = target.object_id + key = (application.id, object_id) + status_view = get(compiled.status_views_by_target, key, nothing) + isnothing(status_view) && return :missing_status_view + target.model === _application_model(application, object_id) || + return :model + target.status === status_view.status || return :status_view + target.canonical_status === status_view.canonical_status || + return :canonical_status + _model_execution_inputs_match( + target.input_bindings, + status_view.temporal_inputs, + ) || + return :temporal_inputs + _model_execution_outputs_match( + target.output_bindings, + application, + output_retention, + ) || return :output_bindings + target.call_bindings === + get(compiled.call_bindings_by_target, key, ()) || + return :call_bindings + target.environment_binding === _environment_binding_for( + env_bindings, + application.id, + object_id, + ) || return :environment_binding + return nothing +end + +function _count_model_execution_target_rebuild!(::Nothing, reason) + return nothing +end + +function _count_model_execution_target_rebuild!( + performance::RuntimePerformanceCounters, + reason, +) + counter = if reason === :new_target + :execution_target_rebuild_new + elseif reason === :missing_status_view + :execution_target_rebuild_missing_status_view + elseif reason === :model + :execution_target_rebuild_model + elseif reason === :status_view + :execution_target_rebuild_status_view + elseif reason === :canonical_status + :execution_target_rebuild_canonical_status + elseif reason === :model_bundle + :execution_target_rebuild_model_bundle + elseif reason === :temporal_inputs + :execution_target_rebuild_temporal_inputs + elseif reason === :output_bindings + :execution_target_rebuild_output_bindings + elseif reason === :call_bindings + :execution_target_rebuild_call_bindings + elseif reason === :environment_binding + :execution_target_rebuild_environment_binding + else + :execution_target_rebuild_unknown + end + return _runtime_performance_count!(performance, counter) +end + +function _model_execution_group_reusable( + group::CompiledApplicationExecutionGroup, + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + output_retention=nothing, +) + group.application === application || return false + target_index = 0 + for batch in group.batches + for target in batch.targets + target_index += 1 + target_index <= length(application.target_ids) || return false + target.object_id == application.target_ids[target_index] || + return false + isnothing(_model_execution_target_change_reason( + target, + compiled, + env_bindings, + application, + output_retention, + )) || return false + end + end + return target_index == length(application.target_ids) +end + +function _sorted_execution_target_position( + targets, + object_id::ObjectId, +) + lower = firstindex(targets) + upper = lastindex(targets) + while lower <= upper + middle = (lower + upper) >>> 1 + candidate_id = @inbounds targets[middle].object_id + candidate_id == object_id && return (true, middle) + if _object_id_isless(candidate_id, object_id) + lower = middle + 1 + else + upper = middle - 1 + end + end + return (false, lower) +end + +function _model_execution_target_location( + group::CompiledApplicationExecutionGroup, + object_id::ObjectId, +) + for (batch_index, batch) in pairs(group.batches) + found, target_index = + _sorted_execution_target_position(batch.targets, object_id) + found && return (batch_index, target_index) + end + return nothing +end + +function _model_execution_target_insertion( + group::CompiledApplicationExecutionGroup, + object_id::ObjectId, +) + for (batch_index, batch) in pairs(group.batches) + isempty(batch.targets) && continue + if !_object_id_isless(last(batch.targets).object_id, object_id) + _, target_index = + _sorted_execution_target_position( + batch.targets, + object_id, + ) + return (batch_index, target_index) + end + end + isempty(group.batches) && return nothing + batch_index = lastindex(group.batches) + return ( + batch_index, + length(group.batches[batch_index].targets) + 1, + ) +end + +function _model_execution_batch_accepts_target( + batch::CompiledExecutionBatch, + target::CompiledExecutionTarget, + env_bindings, + application, +) + typeof(target) === eltype(batch.targets) || return false + provider = _compiled_model_execution_environment_provider( + env_bindings, + application, + target, + ) + return isequal(provider, batch.environment_provider) +end + +function _refresh_model_execution_group_delta!( + previous_group::CompiledApplicationExecutionGroup, + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + application::CompiledModelApplication, + temporal_streams, + output_retention, + constants, + performance, + changed_execution_target_ids, +) + previous_group.application === application || return nothing + changed_object_ids = ObjectId[ + object_id + for (application_id, object_id) in changed_execution_target_ids + if application_id == application.id + ] + isempty(changed_object_ids) && return nothing + _sort_object_ids!(changed_object_ids) + actions = NamedTuple[] + targets_constructed = 0 + for object_id in changed_object_ids + location = _model_execution_target_location( + previous_group, + object_id, + ) + is_current_target = + first(_sorted_object_id_position(application.target_ids, object_id)) + if !is_current_target + isnothing(location) || push!( + actions, + ( + kind=:remove, + object_id=object_id, + target=nothing, + reason=nothing, + ), + ) + continue + end + previous_target = if isnothing(location) + nothing + else + batch_index, target_index = location + previous_group.batches[batch_index].targets[target_index] + end + change_reason = isnothing(previous_target) ? + :new_target : + _model_execution_target_change_reason( + previous_target, + compiled, + env_bindings, + application, + output_retention, + ) + isnothing(change_reason) && continue + target = _compiled_model_execution_target( + compiled, + env_bindings, + application, + object_id, + temporal_streams, + output_retention, + constants, + ) + insertion = isnothing(location) ? + _model_execution_target_insertion( + previous_group, + object_id, + ) : location + isnothing(insertion) && return nothing + batch = previous_group.batches[first(insertion)] + _model_execution_batch_accepts_target( + batch, + target, + env_bindings, + application, + ) || return nothing + push!( + actions, + ( + kind=isnothing(location) ? :insert : :replace, + object_id=object_id, + target=target, + reason=change_reason, + ), + ) + targets_constructed += 1 + end + + for action in actions + isnothing(action.reason) && continue + _count_model_execution_target_rebuild!( + performance, + action.reason, + ) + end + isempty(actions) || _runtime_performance_count!( + performance, + :execution_groups_updated_in_place, + ) + batches = copy(previous_group.batches) + working_group = CompiledApplicationExecutionGroup(application, batches) + for action in actions + location = _model_execution_target_location( + working_group, + action.object_id, + ) + if action.kind === :remove + isnothing(location) && continue + batch_index, target_index = location + deleteat!(batches[batch_index].targets, target_index) + elseif action.kind === :replace + isnothing(location) && return nothing + batch_index, target_index = location + batches[batch_index].targets[target_index] = action.target + else + insertion = _model_execution_target_insertion( + working_group, + action.object_id, + ) + isnothing(insertion) && return nothing + batch_index, target_index = insertion + _insert_with_spare_capacity!( + batches[batch_index].targets, + target_index, + action.target, + ) + end + end + filter!(batch -> !isempty(batch.targets), batches) + return ( + group=isempty(batches) ? nothing : working_group, + targets_constructed=targets_constructed, + batches_constructed=0, + ) +end + +function _refresh_model_execution_plan( + previous::CompiledExecutionPlan, + compiled::CompiledCompositeModel, + env_bindings::CompiledEnvironmentBindings, + temporal_streams=nothing, + output_retention=nothing, + constants=nothing, + performance=nothing, + changed_application_ids=nothing, + changed_execution_target_ids=compiled.changed_execution_target_ids, +) + manual_application_ids = _manual_call_application_ids(compiled) + previous_groups = Dict( + group.application.id => group for group in previous.groups + ) + groups = CompiledApplicationExecutionGroup[] + batches = AbstractExecutionBatch[] + sizehint!(groups, length(previous.groups)) + sizehint!(batches, length(previous.batches)) + targets_constructed = 0 + batches_constructed = 0 + groups_reused = 0 + + for application in _ordered_model_applications(compiled) + application.id in manual_application_ids && continue + previous_group = get(previous_groups, application.id, nothing) + if !isnothing(previous_group) + if !isnothing(changed_application_ids) + if !(application.id in changed_application_ids) + push!(groups, previous_group) + append!(batches, previous_group.batches) + groups_reused += 1 + continue + end + delta_group = _refresh_model_execution_group_delta!( + previous_group, + compiled, + env_bindings, + application, + temporal_streams, + output_retention, + constants, + performance, + changed_execution_target_ids, + ) + if !isnothing(delta_group) + if !isnothing(delta_group.group) + push!(groups, delta_group.group) + append!(batches, delta_group.group.batches) + end + targets_constructed += + delta_group.targets_constructed + batches_constructed += + delta_group.batches_constructed + continue + end + elseif _model_execution_group_reusable( + previous_group, + compiled, + env_bindings, + application, + output_retention, + ) + push!(groups, previous_group) + append!(batches, previous_group.batches) + groups_reused += 1 + continue + end + end + + targets = Any[] + previous_batch_index = 1 + previous_target_index = 1 + for object_id in application.target_ids + previous_target = nothing + while !isnothing(previous_group) && + previous_batch_index <= length(previous_group.batches) + previous_batch = + previous_group.batches[previous_batch_index] + if previous_target_index > length(previous_batch.targets) + previous_batch_index += 1 + previous_target_index = 1 + continue + end + candidate = + previous_batch.targets[previous_target_index] + if candidate.object_id == object_id + previous_target = candidate + previous_target_index += 1 + break + elseif _object_id_isless(candidate.object_id, object_id) + previous_target_index += 1 + continue + end + break + end + target_key = (application.id, object_id) + change_reason = if isnothing(previous_target) + :new_target + elseif !isnothing(changed_application_ids) && + !(target_key in compiled.changed_execution_target_ids) + nothing + else + _model_execution_target_change_reason( + previous_target, + compiled, + env_bindings, + application, + output_retention, + ) + end + if isnothing(change_reason) + push!(targets, previous_target) + else + push!( + targets, + _compiled_model_execution_target( + compiled, + env_bindings, + application, + object_id, + temporal_streams, + output_retention, + constants, + ), + ) + _count_model_execution_target_rebuild!( + performance, + change_reason, + ) + targets_constructed += 1 + end + end + + application_batches = AbstractExecutionBatch[] + _append_model_execution_batches!( + application_batches, + env_bindings, + application, + targets, + ) + isempty(application_batches) && continue + push!( + groups, + CompiledApplicationExecutionGroup( + application, + application_batches, + ), + ) + append!(batches, application_batches) + batches_constructed += length(application_batches) + end + + return ( + plan=CompiledExecutionPlan( + groups, + batches, + compiled.revision, + env_bindings.environment_revision, + ), + targets_constructed=targets_constructed, + batches_constructed=batches_constructed, + groups_reused=groups_reused, + ) +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), + input_bindings_type=fieldtype(eltype(batch.targets), :input_bindings), + call_bindings_type=fieldtype(eltype(batch.targets), :call_bindings), + call_capability=isempty(first(batch.targets).call_bindings) ? + :no_calls : + :compiled_calls, + 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 _model_temporal_retention_horizon( + binding, + consumer, + source, + timeline, +) + window_steps = _model_input_window_steps(binding, consumer, timeline) + return 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 +end + +function _model_output_retention_covers_binding( + plan::OutputRetentionPlan, + compiled::CompiledCompositeModel, + binding, +) + plan.retain_all && return true + consumer = _compiled_application_by_id(compiled, binding.application_id) + for application_id in binding.source_application_ids + source = _compiled_application_by_id(compiled, application_id) + required = _model_temporal_retention_horizon( + binding, + consumer, + source, + compiled.timeline, + ) + key = (application_id, binding.source_var) + key in plan.temporal_dependencies || return false + get(plan.dependency_horizons, key, -Inf) >= required || return false + end + return true +end + +function _model_output_retention_covers_addition( + plan::OutputRetentionPlan, + compiled::CompiledCompositeModel, +) + for key in compiled.changed_execution_target_ids + view = get(compiled.status_views_by_target, key, nothing) + isnothing(view) && continue + for temporal_input in view.temporal_inputs + _model_output_retention_covers_binding( + plan, + compiled, + temporal_input.binding, + ) || return false + end + end + return true +end + +function _same_model_output_retention( + previous::OutputRetentionPlan, + current::OutputRetentionPlan, +) + return previous.retain_all == current.retain_all && + previous.temporal_dependencies == + current.temporal_dependencies && + previous.requested_outputs == current.requested_outputs && + previous.dependency_horizons == current.dependency_horizons && + previous.retained_application_ids == + current.retained_application_ids && + previous.retained_outputs_by_application == + current.retained_outputs_by_application && + previous.dependency_outputs_by_application == + current.dependency_outputs_by_application && + previous.historical_outputs_by_application == + current.historical_outputs_by_application +end + +function _model_status_view_refresh_is_pure_addition( + previous_status_views, + current_status_views, +) + previous_keys = keys(previous_status_views) + current_keys = keys(current_status_views) + all(key -> haskey(current_status_views, key), previous_keys) || return false + new_keys = [ + key for key in current_keys if !haskey(previous_status_views, key) + ] + isempty(new_keys) && return false + previous_object_ids = Set(last(key) for key in previous_keys) + added_object_ids = Set( + last(key) for key in new_keys if !(last(key) in previous_object_ids) + ) + isempty(added_object_ids) && return false + return all(key -> last(key) in added_object_ids, new_keys) +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) + for application_id in binding.source_application_ids + source = _compiled_application_by_id(compiled, application_id) + required = _model_temporal_retention_horizon( + binding, + consumer, + source, + timeline, + ) + 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}}() + dependency_outputs_by_application = Dict{Symbol,Vector{Symbol}}() + historical_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, + ) + key = (application_id, variable) + destination = !retain_all && + key in temporal_dependencies && + !(key in requested_outputs) ? + dependency_outputs_by_application : + historical_outputs_by_application + push!(get!(destination, application_id, Symbol[]), variable) + end + for outputs_by_application in ( + retained_outputs_by_application, + dependency_outputs_by_application, + historical_outputs_by_application, + ) + for variables in values(outputs_by_application) + sort!(variables; by=string) + end + 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, + dependency_outputs_by_application, + historical_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) + plan = compile_model_output_retention( + compiled, + output_requests; + retain_all=retain_all, + ) + return _explain_output_retention(compiled, plan) +end + +@noinline function _synchronize_call_targets_slow!( + targets::CallTargets, + context::RunContext, +) + targets.time = context.time + targets.publication_allowed = context.publication_allowed + targets.environment = context.environment + return targets +end + +@inline _find_call_targets(::Tuple{}, ::Val, ::RunContext) = nothing + +@inline function _find_call_targets( + calls::Tuple, + ::Val{name}, + context::RunContext, +) where {name} + targets = first(calls) + if _compiled_call_name(targets.binding) === name + targets.time == context.time && + targets.publication_allowed == context.publication_allowed && + targets.environment === context.environment && + return targets + return _synchronize_call_targets_slow!(targets, context) + end + return _find_call_targets(Base.tail(calls), Val(name), context) +end + +Base.@constprop :aggressive function _model_call_targets( + context::RunContext, + name::Symbol, +) + found = _find_call_targets(context.calls, Val(name), context) + 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_view = _model_status_view_for_application( + targets.compiled, + application, + object_id, + ) + call_bindings = get( + targets.compiled.call_bindings_by_target, + (application.id, object_id), + (), + ) + calls = _runtime_call_targets( + targets.compiled, + targets.environment_bindings, + call_bindings, + targets.temporal_streams, + targets.output_retention, + targets.time, + targets.constants, + targets.publication_allowed, + targets.environment, + ) + return CallTarget( + targets.compiled, + targets.environment_bindings, + application, + object_id, + _application_model(application, object_id), + status_view.status, + status_view.canonical_status, + status_view.temporal_inputs, + calls, + _environment_binding_for( + targets.environment_bindings, + application.id, + object_id, + ), + targets.temporal_streams, + targets.output_retention, + targets.time, + targets.constants, + targets.publication_allowed, + targets.environment, + ) +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; objects=nothing) + +Return a cached, non-executing vector-like view of the targets declared for +`name` with `ModelSpec(...; calls=...)`. The collection is empty for an unresolved +`OptionalOne`, has one element for `One`, and contains every resolved target +for `Many`. + +When `objects` is provided, resolve the declared call against the current +object topology and restrict the result to those objects. This explicit form is +intended for models that create objects and immediately initialize selected +applications on them. Ordinary scheduled execution still observes structural +changes at the next timestep boundary. Each requested object may be an +[`ObjectId`](@ref), [`Object`](@ref), an MTG node, or the [`Status`](@ref) +returned by [`add_organ!`](@ref). + +Use this accessor with [`run_call!(::CallTarget)`](@ref) when targets need +different sampled environments, selective execution, a controlled order, or separate +trial and accepted publication. +""" +Base.@constprop :aggressive function call_targets( + context::RunContext, + name::Symbol, + ; + objects=nothing, +) + isnothing(objects) || + return _current_topology_call_targets(context, name, objects) + return _model_call_targets(context, name) +end + +function _call_target_object_id(model::CompositeModel, target) + target isa ObjectId && return target + target isa Object && return target.id + if target isa Status + hasproperty(target, :node) || error( + "A `Status` used as a hard-call object filter must contain its source `node`." + ) + return _call_target_object_id(model, target.node) + end + adapter = model.source_adapter + if adapter isa MTGObjectAdapter && target isa MultiScaleTreeGraph.Node + return ObjectId(adapter.id(target)) + end + return ObjectId(target) +end + +function _call_target_object_ids(model::CompositeModel, objects) + requested = objects isa Union{Tuple,AbstractVector,AbstractSet} ? + objects : (objects,) + ids = ObjectId[_call_target_object_id(model, object) for object in requested] + unique!(ids) + return ids +end + +function _partial_model_application( + application::CompiledModelApplication, + target_ids::Vector{ObjectId}, +) + return CompiledModelApplication( + application.id, + application.spec, + application.process, + application.name, + target_ids, + application.applies_to, + application.timestep, + application.clock, + application.model_overrides, + ) +end + +struct _ApplicationsByObjectOverlay{B,O} + base::B + overlay::O +end + +mutable struct _TargetedApplicationSet{A,B} + applications::A + applications_by_object::B + outputs_prepared::Bool +end + +mutable struct _TargetedTopologyRuntime{CS} + compiled::CS + model_revision::Int + application_sets::Dict{Tuple{Vararg{ObjectId}},Any} + manual_application_ids::Set{Symbol} + application_positions::Dict{Symbol,Int} +end + +@inline function Base.get( + applications::_ApplicationsByObjectOverlay, + object_id, + default, +) + overlay = get(applications.overlay, object_id, nothing) + isnothing(overlay) || return overlay + return get(applications.base, object_id, default) +end + +function _targeted_topology_runtime!( + context::RunContext, + model::CompositeModel, +) + cached = context.targeted_topology_runtime + if cached isa _TargetedTopologyRuntime && + cached.compiled === context.compiled && + cached.model_revision == model.revision + return cached + end + compiled = context.compiled + runtime = _TargetedTopologyRuntime( + compiled, + model.revision, + Dict{Tuple{Vararg{ObjectId}},Any}(), + _manual_call_application_ids(compiled.call_bindings), + Dict( + application_id => index + for (index, application_id) in pairs(compiled.application_order) + ), + ) + context.targeted_topology_runtime = runtime + return runtime +end + +function _new_object_applications( + model::CompositeModel, + compiled::CompiledCompositeModel, + requested_ids, +) + by_object = Dict{ObjectId,Vector{Any}}() + partial_applications = CompiledModelApplication[] + for application in compiled.applications + target_ids = ObjectId[ + object_id for object_id in requested_ids + if _selector_matches_object_id( + model, + application.applies_to, + object_id, + ) + ] + isempty(target_ids) && continue + new_target_count = length(application.target_ids) + + count( + object_id -> !(object_id in application.target_ids), + target_ids, + ) + if (application.applies_to isa One && new_target_count != 1) || + (application.applies_to isa OptionalOne && new_target_count > 1) + return nothing + end + partial = _partial_model_application(application, target_ids) + push!(partial_applications, partial) + for object_id in target_ids + applications = get!(by_object, object_id) do + copy( + get( + compiled.applications_by_object, + object_id, + Any[], + ), + ) + end + any(candidate -> candidate.id == application.id, applications) || + push!(applications, partial) + end + end + return ( + partial_applications, + _ApplicationsByObjectOverlay( + compiled.applications_by_object, + by_object, + ), + ) +end + +function _targeted_application_set!( + runtime::_TargetedTopologyRuntime, + model::CompositeModel, + requested_ids, +) + key = Tuple(requested_ids) + return get!(runtime.application_sets, key) do + applications = _new_object_applications( + model, + runtime.compiled, + requested_ids, + ) + isnothing(applications) && return nothing + output_applications, applications_by_object = applications + return _TargetedApplicationSet( + output_applications, + applications_by_object, + false, + ) + end +end + +function _targeted_callee_applications( + binding::CompiledModelCallBinding, + requested_ids, + partial_applications, +) + applications = CompiledModelApplication[] + unresolved_ids = ObjectId[] + for object_id in requested_ids + matched = false + for application in partial_applications + object_id in application.target_ids || continue + isnothing(binding.process) || + application.process == binding.process || + continue + isnothing(binding.application) || + application.id == binding.application || + continue + calls = model_calls(application.spec) + calls isa NamedTuple && !isempty(keys(calls)) && return nothing + any(candidate -> candidate.id == application.id, applications) || + push!(applications, application) + matched = true + end + matched || push!(unresolved_ids, object_id) + end + isempty(unresolved_ids) || error( + "Hard call `$(binding.call)` from application `$(binding.application_id)` does not ", + "resolve requested object(s) `$(Tuple(id.value for id in unresolved_ids))`." + ) + return applications +end + +function _applications_use_only_global_environment( + model::CompositeModel, + applications, +) + return all(applications) do application + backend = _environment_backend_from_config( + model, + environment_config(application.spec), + ) + return isnothing(backend) || backend isa GlobalConstant + end +end + +function _targeted_call_environment_bindings( + model::CompositeModel, + compiled::CompiledCompositeModel, + cached::CompiledEnvironmentBindings, + applications, + applications_by_id, +) + _applications_use_only_global_environment(model, applications) || + return nothing + partial = _compile_environment_bindings_for_applications( + model, + applications, + ) + _validate_model_environment_inputs!(partial, applications_by_id) + by_target = Dict( + (binding.application_id, binding.object_id) => binding + for binding in partial + ) + return _compiled_environment_bindings( + model, + compiled, + partial, + by_target, + _model_environment_samplers(partial), + cached.prepared_global_environment, + cached.sample_cache, + ) +end + +function _targeted_new_object_call_targets( + context::RunContext, + name::Symbol, + requested_ids, +) + model = runtime_model(context) + bindings_dirty(model) || return nothing + dirty_ids = model.binding_dirty_objects + isnothing(dirty_ids) && return nothing + all(object_id -> object_id in dirty_ids, requested_ids) || return nothing + + cached_targets = _model_call_targets(context, name) + binding = cached_targets.binding + binding.multiplicity != :many && length(requested_ids) > 1 && + error( + "Hard call `$(name)` from application `$(context.application.id)` ", + "accepts at most one requested object.", + ) + default_scope = _default_dependency_scope(model, context.object_id) + unresolved_ids = ObjectId[ + object_id for object_id in requested_ids + if !_selector_matches_object_id( + model, + binding.selector, + object_id; + context=context.object_id, + default_to_context=true, + default_scope=default_scope, + ) + ] + isempty(unresolved_ids) || error( + "Hard call `$(name)` from application `$(context.application.id)` does not ", + "resolve requested object(s) `$(Tuple(id.value for id in unresolved_ids))`." + ) + + compiled = context.compiled + targeted_runtime = _targeted_topology_runtime!(context, model) + application_set = _targeted_application_set!( + targeted_runtime, + model, + requested_ids, + ) + isnothing(application_set) && return nothing + output_applications = application_set.applications + applications_by_object = application_set.applications_by_object + callee_applications = _targeted_callee_applications( + binding, + requested_ids, + output_applications, + ) + isnothing(callee_applications) && return nothing + + if !application_set.outputs_prepared + _prepare_model_output_statuses!(model, output_applications) + application_set.outputs_prepared = true + end + input_bindings = CompiledModelInputBinding[] + for application in callee_applications + for object_id in application.target_ids + _compile_added_consumer_bindings!( + input_bindings, + model, + application, + object_id, + targeted_runtime.manual_application_ids, + applications_by_object, + compiled.applications_by_id, + ) + end + end + _prepare_model_input_defaults!(model, callee_applications) + _wire_model_input_carriers!(model, input_bindings) + _validate_model_required_inputs!( + model, + callee_applications, + input_bindings, + ) + any( + binding -> binding.carrier_hint == :temporal_stream, + input_bindings, + ) && return nothing + + targeted_input_bindings = _index_model_bindings( + input_bindings, + :application_id, + :consumer_id, + ) + + environment_bindings = _targeted_call_environment_bindings( + model, + compiled, + context.environment_bindings, + callee_applications, + compiled.applications_by_id, + ) + isnothing(environment_bindings) && return nothing + + targets = CallTarget[] + for partial_application in callee_applications + application = compiled.applications_by_id[partial_application.id] + for object_id in partial_application.target_ids + key = (application.id, object_id) + status_view = _compile_model_status_view( + model, + partial_application, + object_id, + get(targeted_input_bindings, key, ()), + compiled.applications_by_id, + targeted_runtime.application_positions, + ) + push!( + targets, + CallTarget( + compiled, + environment_bindings, + application, + object_id, + _application_model(application, object_id), + status_view.status, + status_view.canonical_status, + status_view.temporal_inputs, + (), + _environment_binding_for( + environment_bindings, + application.id, + object_id, + ), + context.temporal_streams, + context.output_retention, + context.time, + context.constants, + context.publication_allowed, + context.environment, + ), + ) + end + end + return targets +end + +function _current_topology_call_targets( + context::RunContext, + name::Symbol, + objects, +) + model = runtime_model(context) + requested_ids = _call_target_object_ids(model, objects) + targeted = _targeted_new_object_call_targets( + context, + name, + requested_ids, + ) + isnothing(targeted) || return targeted + compiled = refresh_bindings!(model) + environment_bindings = refresh_environment_bindings!(model, compiled) + bindings = get( + compiled.call_bindings_by_target, + (context.application.id, context.object_id), + (), + ) + binding = findfirst( + candidate -> _compiled_call_name(candidate) === name, + bindings, + ) + if isnothing(binding) + available = Symbol[candidate.call for candidate in bindings] + error( + "Application `$(context.application.id)` on object ", + "`$(context.object_id.value)` did not declare call `$(name)`. ", + "Declared calls: $(available).", + ) + end + resolved_binding = bindings[binding] + unresolved_ids = setdiff(requested_ids, resolved_binding.callee_object_ids) + isempty(unresolved_ids) || error( + "Hard call `$(name)` from application `$(context.application.id)` does not ", + "resolve requested object(s) `$(Tuple(id.value for id in unresolved_ids))`." + ) + selected_binding = CompiledModelCallBinding( + resolved_binding.application_id, + resolved_binding.consumer_id, + resolved_binding.call, + resolved_binding.selector, + resolved_binding.origin, + requested_ids, + resolved_binding.callee_application_ids, + resolved_binding.process, + resolved_binding.application, + resolved_binding.multiplicity, + ) + return CallTargets( + compiled, + environment_bindings, + selected_binding, + context.temporal_streams, + context.output_retention, + context.time, + context.constants, + context.publication_allowed, + context.environment, + ) +end + +function _environment_binding_for_current_context(context::RunContext) + binding = _environment_binding_for( + context.environment_bindings, + context.application.id, + context.object_id, + ) + if isnothing(binding) || isnothing(binding.backend) + error( + "Cannot commit environment state for `$(context.application.id)` on object ", + "`$(context.object_id.value)`: no environment binding is configured. ", + "Attach an environment with `CompositeModel(...; environment=...)` and ", + "`ModelSpec(model; environment=Environment(...))`." + ) + end + return binding +end + +function _validate_environment_commit(context::RunContext, state) + declared = Symbol.(collect(keys(environment_outputs_(context.application.spec)))) + isempty(declared) && error( + "Application `$(context.application.id)` cannot commit environment state because ", + "its model declares no `environment_outputs_` variables.", + ) + missing = Symbol[variable for variable in declared if !hasproperty(state, variable)] + isempty(missing) || error( + "Environment state committed by application `$(context.application.id)` is missing ", + "declared `environment_outputs_` variable(s) `$(Tuple(missing))`.", + ) + return nothing +end + +""" + commit_environment!(context::RunContext, state) + +Commit an accepted environment `state` through the opaque handle compiled for +the currently running model application/object. The model must declare the +variables it commits with `environment_outputs_`. + +Commits are ignored while the current model is executing as a non-publishing +hard call. Trial environment states should instead be passed to +`run_call!(context, name; environment=state)`. +""" +function commit_environment!(context::RunContext, state) + context.publication_allowed || return nothing + binding = _environment_binding_for_current_context(context) + _validate_environment_commit(context, state) + return commit_environment!( + binding.backend, + binding.handle, + state, + context.time, + ) +end + +function commit_environment!(context, state) + throw( + ArgumentError( + "`commit_environment!` requires the compiled RunContext passed to a model kernel; got $(typeof(context)).", + ), + ) +end + +""" + run_call!(target::CallTarget; publish=false, sampled_environment) + +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 +`sampled_environment` is provided, it is forwarded directly to this target +instead of using its compiled environment binding. This is the fine-grained +escape hatch for one selected target; execute provider-aware trial states for all targets with +`run_call!(context, name; environment=state)`. + +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(context, :leaf_energy) +for (target, target_environment) in zip(targets, environments_by_leaf) + run_call!( + target; + sampled_environment=target_environment, + publish=false, + ) +end + +accepted = accepted_environment(model, status) +commit_environment!(context, accepted) +for target in targets + run_call!(target; publish=true) +end +``` +""" +@inline function _run_call!( + target::CallTarget, + publish::Bool, + sampled_environment, + environment, +) + status = _materialize_model_inputs!( + target.status, + target.temporal_inputs, + target.compiled, + target.application, + target.temporal_streams, + target.time, + ) + environment_value = sampled_environment isa UnspecifiedModelEnvironment ? + _model_environment_for_binding( + target.environment_bindings, + target.application, + target.environment_binding, + target.time, + environment, + ) : sampled_environment + publication_allowed = publish && target.publication_allowed + _prepare_runtime_call_targets!( + target.calls, + target.compiled, + target.environment_bindings, + target.temporal_streams, + target.output_retention, + target.time, + target.constants, + publication_allowed, + environment, + ) + context = RunContext( + target.compiled, + target.environment_bindings, + target.application, + target.object_id, + target.calls, + target.temporal_streams, + target.output_retention, + target.time, + target.constants, + publication_allowed, + environment, + ) + run!( + target.model, + status, + environment_value, + target.constants, + context, + ) + if publication_allowed + _model_publish_outputs!( + target.temporal_streams, + target.application, + target.object_id, + status, + target.time, + target.output_retention, + ) + end + return target +end + +function run_call!( + target::CallTarget; + publish::Bool=false, + sampled_environment=_UNSPECIFIED_SCENE_ENVIRONMENT, +) + return _run_call!( + target, + publish, + sampled_environment, + target.environment, + ) +end + +@inline function _compiled_call_execution_target( + targets::CallTargets, + application::CompiledModelApplication, + object_id::ObjectId, +) + key = (application.id, object_id) + return get!(targets.execution_targets, key) do + _compiled_model_execution_target( + targets.compiled, + targets.environment_bindings, + application, + object_id, + targets.temporal_streams, + targets.output_retention, + targets.constants, + ) + end +end + +@inline function _run_compiled_call_target!( + targets::CallTargets, + application::CompiledModelApplication, + object_id::ObjectId, + publish::Bool, + sampled_environment, + environment, +) + target = _compiled_call_execution_target( + targets, + application, + object_id, + ) + status = _materialize_model_inputs!( + target.status, + target.input_bindings, + targets.compiled, + application, + targets.temporal_streams, + targets.time, + ) + environment_value = sampled_environment isa UnspecifiedModelEnvironment ? + _model_environment_for_binding( + targets.environment_bindings, + application, + target.environment_binding, + targets.time, + environment, + ) : sampled_environment + publication_allowed = publish && targets.publication_allowed + reusable_context = + target.context isa RunContext && + typeof(target.context.environment) === typeof(environment) + context = _prepare_model_execution_context!( + reusable_context ? target.context : nothing, + targets.compiled, + targets.environment_bindings, + application, + object_id, + targets.temporal_streams, + targets.output_retention, + targets.time, + targets.constants, + publication_allowed, + environment, + ) + reusable_context && (target.context = context) + run!( + target.model, + status, + environment_value, + targets.constants, + context, + ) + if publication_allowed + _model_publish_outputs!( + targets.temporal_streams, + application, + object_id, + status, + targets.time, + targets.output_retention, + ) + end + return nothing +end + +function _run_call_targets!( + targets::CallTargets, + publish::Bool, + sampled_environment, + environment, +) + 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 + _run_compiled_call_target!( + targets, + application, + object_id, + publish, + sampled_environment, + environment, + ) + end + end + return targets +end + +function _run_call_targets!( + targets, + publish::Bool, + sampled_environment, + environment, +) + for target in targets + _run_call!( + target, + publish, + sampled_environment, + environment, + ) + end + return targets +end + +""" + run_call!(context::RunContext, name::Symbol; environment, 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. + +When `environment` is supplied, every target keeps its own opaque compiled +backend handle and samples that transient backend-specific state. The state is +inherited by nested hard calls. Omit it to sample the committed backend state. + +For finer-grained target selection, order, or direct per-target sampled environments, +use [`call_targets`](@ref) and [`run_call!(::CallTarget)`](@ref). Commit an +accepted mutable state with [`commit_environment!`](@ref) before publishing the +accepted descendants. +""" +function run_call!( + context::RunContext, + name::Symbol; + environment=_NO_ENVIRONMENT_OVERRIDE, + publish::Bool=false, + objects=nothing, +) + targets = isnothing(objects) ? + call_targets(context, name) : + call_targets(context, name; objects=objects) + selected_environment = environment isa NoEnvironmentOverride ? + context.environment : + environment + return _run_call_targets!( + targets, + publish, + _UNSPECIFIED_SCENE_ENVIRONMENT, + selected_environment, + ) +end + +function run_call!( + context, + name::Symbol; + environment=_NO_ENVIRONMENT_OVERRIDE, + publish::Bool=false, + objects=nothing, +) + throw( + ArgumentError( + "Hard call `$(name)` requires the compiled RunContext passed to a model kernel; got $(typeof(context)).", + ), + ) +end + +function _model_output_selection(outputs) + outputs === :all && return (OutputRequest[], true) + outputs === :none && return (OutputRequest[], false) + outputs isa Symbol && error( + "Unsupported output selection `$(outputs)`. Use `:all`, `:none`, an `OutputRequest`, or a vector of requests.", + ) + requests = _normalize_output_requests(outputs) + return requests, false +end + +function _refresh_simulation_runtime!(simulation::Simulation) + model = simulation.model + if bindings_dirty(model) || + simulation.compiled.revision != model_revision(model) + dirty_object_count = isnothing(model.binding_dirty_objects) ? + length(model.registry.objects) : + length(model.binding_dirty_objects) + previous_status_views = isnothing(simulation.performance) ? + nothing : + copy( + simulation.compiled.status_views_by_target, + ) + previous_input_bindings = isnothing(simulation.performance) ? + nothing : + copy( + simulation.compiled.input_bindings_by_target, + ) + previous_call_bindings = isnothing(simulation.performance) ? + nothing : + copy( + simulation.compiled.call_bindings_by_target, + ) + previous_environment_bindings = isnothing(simulation.performance) ? + nothing : + copy( + simulation.environment_bindings.by_target, + ) + started_at = _runtime_performance_start(simulation.performance) + simulation.compiled = refresh_bindings!( + model; + performance=simulation.performance, + ) + _runtime_performance_finish!( + simulation.performance, + :binding_refresh, + started_at, + ) + _runtime_performance_count!( + simulation.performance, + :binding_refreshes, + ) + _runtime_performance_count!( + simulation.performance, + :dirty_binding_objects, + dirty_object_count, + ) + if !isnothing(simulation.performance) + _runtime_performance_count!( + simulation.performance, + :status_views_constructed, + count( + key_and_view -> + get( + previous_status_views, + first(key_and_view), + nothing, + ) !== last(key_and_view), + simulation.compiled.status_views_by_target, + ), + ) + _runtime_performance_count!( + simulation.performance, + :input_binding_targets_replaced, + _changed_compiled_binding_target_count( + previous_input_bindings, + simulation.compiled.input_bindings_by_target, + ), + ) + _runtime_performance_count!( + simulation.performance, + :input_binding_targets_removed, + _removed_compiled_binding_target_count( + previous_input_bindings, + simulation.compiled.input_bindings_by_target, + ), + ) + _runtime_performance_count!( + simulation.performance, + :call_binding_targets_replaced, + _changed_compiled_binding_target_count( + previous_call_bindings, + simulation.compiled.call_bindings_by_target, + ), + ) + _runtime_performance_count!( + simulation.performance, + :call_binding_targets_removed, + _removed_compiled_binding_target_count( + previous_call_bindings, + simulation.compiled.call_bindings_by_target, + ), + ) + end + pure_object_addition = + simulation.compiled.status_view_refresh_is_pure_addition + output_retention_reused = pure_object_addition && + _model_output_retention_covers_addition( + simulation.output_retention, + simulation.compiled, + ) + if !output_retention_reused + started_at = _runtime_performance_start(simulation.performance) + refreshed_output_retention = compile_model_output_retention( + simulation.compiled, + simulation.output_requests; + retain_all=simulation.output_retention.retain_all, + ) + _runtime_performance_finish!( + simulation.performance, + :output_retention_compile, + started_at, + ) + _runtime_performance_count!( + simulation.performance, + :output_retention_compiles, + ) + output_retention_reused = _same_model_output_retention( + simulation.output_retention, + refreshed_output_retention, + ) + output_retention_reused || + (simulation.output_retention = refreshed_output_retention) + end + if output_retention_reused + _runtime_performance_count!( + simulation.performance, + :output_retention_reuses, + ) + end + _initialize_model_output_streams!( + simulation.temporal_streams, + simulation.compiled, + simulation.output_retention, + ) + started_at = _runtime_performance_start(simulation.performance) + simulation.environment_bindings = refresh_environment_bindings!( + model, + simulation.compiled, + ) + _runtime_performance_finish!( + simulation.performance, + :environment_refresh, + started_at, + ) + _runtime_performance_count!( + simulation.performance, + :environment_refreshes, + ) + if !isnothing(simulation.performance) + _runtime_performance_count!( + simulation.performance, + :environment_bindings_replaced, + _changed_environment_binding_count( + previous_environment_bindings, + simulation.environment_bindings.by_target, + ), + ) + _runtime_performance_count!( + simulation.performance, + :environment_bindings_removed, + _removed_environment_binding_count( + previous_environment_bindings, + simulation.environment_bindings.by_target, + ), + ) + end + changed_execution_application_ids = output_retention_reused ? + simulation.compiled.changed_execution_application_ids : + nothing + started_at = _runtime_performance_start(simulation.performance) + execution_refresh = _refresh_model_execution_plan( + simulation.execution_plan, + simulation.compiled, + simulation.environment_bindings, + simulation.temporal_streams, + simulation.output_retention, + simulation.constants, + simulation.performance, + changed_execution_application_ids, + ) + simulation.execution_plan = execution_refresh.plan + _runtime_performance_finish!( + simulation.performance, + :execution_plan_compile, + started_at, + ) + _runtime_performance_finish!( + simulation.performance, + :execution_plan_and_model_bundle_compile, + started_at, + ) + _runtime_performance_count!( + simulation.performance, + :execution_plan_compiles, + ) + _runtime_performance_count!( + simulation.performance, + :execution_targets_constructed, + execution_refresh.targets_constructed, + ) + _runtime_performance_count!( + simulation.performance, + :execution_batches_constructed, + execution_refresh.batches_constructed, + ) + _runtime_performance_count!( + simulation.performance, + :execution_groups_reused, + execution_refresh.groups_reused, + ) + elseif environment_bindings_dirty(model) || + simulation.environment_bindings.environment_revision != + environment_revision(model) + dirty_environment_object_ids = + isnothing(model.environment_dirty_objects) ? + nothing : + copy(model.environment_dirty_objects) + changed_execution_application_ids = nothing + changed_execution_target_ids = + Set{Tuple{Symbol,ObjectId}}() + if !isnothing(dirty_environment_object_ids) + changed_execution_application_ids = Set{Symbol}() + for object_id in dirty_environment_object_ids + for application in get( + simulation.compiled.applications_by_object, + object_id, + (), + ) + push!( + changed_execution_application_ids, + application.id, + ) + push!( + changed_execution_target_ids, + (application.id, object_id), + ) + end + end + end + previous_environment_bindings = isnothing(simulation.performance) ? + nothing : + copy( + simulation.environment_bindings.by_target, + ) + started_at = _runtime_performance_start(simulation.performance) + simulation.environment_bindings = refresh_environment_bindings!( + model, + simulation.compiled, + ) + _runtime_performance_finish!( + simulation.performance, + :environment_refresh, + started_at, + ) + _runtime_performance_count!( + simulation.performance, + :environment_refreshes, + ) + if !isnothing(simulation.performance) + _runtime_performance_count!( + simulation.performance, + :environment_bindings_replaced, + _changed_environment_binding_count( + previous_environment_bindings, + simulation.environment_bindings.by_target, + ), + ) + _runtime_performance_count!( + simulation.performance, + :environment_bindings_removed, + _removed_environment_binding_count( + previous_environment_bindings, + simulation.environment_bindings.by_target, + ), + ) + end + started_at = _runtime_performance_start(simulation.performance) + execution_refresh = _refresh_model_execution_plan( + simulation.execution_plan, + simulation.compiled, + simulation.environment_bindings, + simulation.temporal_streams, + simulation.output_retention, + simulation.constants, + simulation.performance, + changed_execution_application_ids, + changed_execution_target_ids, + ) + simulation.execution_plan = execution_refresh.plan + _runtime_performance_finish!( + simulation.performance, + :execution_plan_compile, + started_at, + ) + _runtime_performance_finish!( + simulation.performance, + :execution_plan_and_model_bundle_compile, + started_at, + ) + _runtime_performance_count!( + simulation.performance, + :execution_plan_compiles, + ) + _runtime_performance_count!( + simulation.performance, + :execution_targets_constructed, + execution_refresh.targets_constructed, + ) + _runtime_performance_count!( + simulation.performance, + :execution_batches_constructed, + execution_refresh.batches_constructed, + ) + _runtime_performance_count!( + simulation.performance, + :execution_groups_reused, + execution_refresh.groups_reused, + ) + end + return simulation +end + +function _simulation_runtime_dirty(simulation::Simulation) + model = simulation.model + return bindings_dirty(model) || + simulation.compiled.revision != model_revision(model) || + environment_bindings_dirty(model) || + simulation.environment_bindings.environment_revision != + environment_revision(model) +end + +function _run_model_execution_step!(simulation::Simulation, step::Integer) + started_at = _runtime_performance_start(simulation.performance) + empty!(simulation.environment_bindings.sample_cache) + groups = simulation.execution_plan.groups + group_index = firstindex(groups) + completed_applications = nothing + while group_index <= length(groups) + group = groups[group_index] + # A refresh can insert a newly activated group before applications + # that already ran. Skip every completed group, not only the prefix + # used to choose the first resume point. + if !isnothing(completed_applications) && + group.application.id in completed_applications + group_index += 1 + continue + end + for batch in group.batches + if isnothing(simulation.performance) + _run_model_execution_batch!( + batch, + simulation.compiled, + simulation.environment_bindings, + step, + simulation.constants, + simulation.temporal_streams, + simulation.output_retention, + ) + else + _run_model_execution_batch_profiled!( + batch, + simulation.compiled, + simulation.environment_bindings, + step, + simulation.constants, + simulation.temporal_streams, + simulation.output_retention, + simulation.performance, + ) + end + _runtime_performance_count!( + simulation.performance, + :execution_batches_visited, + ) + _runtime_performance_count!( + simulation.performance, + :execution_targets_visited, + length(batch.targets), + ) + end + _runtime_performance_count!( + simulation.performance, + :application_groups_visited, + ) + + if !isnothing(completed_applications) + push!(completed_applications, group.application.id) + end + if !_simulation_runtime_dirty(simulation) + group_index += 1 + continue + end + if isnothing(completed_applications) + completed_applications = Set( + groups[index].application.id + for index in firstindex(groups):group_index + ) + end + added_object_ids = + _incremental_output_request_object_ids(simulation.model) + _refresh_simulation_runtime!(simulation) + _refresh_output_request_targets!(simulation, added_object_ids) + empty!(simulation.environment_bindings.sample_cache) + groups = simulation.execution_plan.groups + next_group = findfirst( + candidate -> + !(candidate.application.id in completed_applications), + groups, + ) + isnothing(next_group) && break + group_index = next_group + end + _runtime_performance_finish!( + simulation.performance, + :step_execution, + started_at, + ) + _runtime_performance_count!( + simulation.performance, + :steps_executed, + ) + return simulation +end + +function _incremental_output_request_object_ids(model::CompositeModel) + bindings_dirty(model) || return nothing + model.binding_dirty_kind == :addition || return nothing + isnothing(model.binding_dirty_objects) && return nothing + return copy(model.binding_dirty_objects) +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 = + _incremental_output_request_object_ids(simulation.model) + _refresh_simulation_runtime!(simulation) + _refresh_output_request_targets!(simulation, added_object_ids) + _run_model_execution_step!(simulation, step) + simulation.current_step = step + end + added_object_ids = + _incremental_output_request_object_ids(simulation.model) + _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 => ( + scale=_model_object(model, id).scale, + initial=getproperty( + _model_status_view_for_application( + compiled, + application, + id, + ).status, + request.var, + ), + start_time=0.0, + ) + 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 + application_id, object_targets = + 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(union(Set(keys(object_targets)), Set(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 + application = _compiled_application_by_id( + simulation.compiled, + application_id, + ) + for object_id in matched_ids + haskey(object_targets, object_id) && continue + object_targets[object_id] = ( + scale=_model_object(simulation.model, object_id).scale, + initial=getproperty( + _model_status_view_for_application( + simulation.compiled, + application, + object_id, + ).status, + request.var, + ), + start_time=float(simulation.current_step + 1), + ) + end + end + return simulation +end + +""" + run!(model; steps=1, constants=Constants(), outputs=:none, performance=false) + +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. Set +`performance=true` to record coarse compiler/runtime timing and work counters +for diagnostics and performance regression tests. +""" +function run!( + model::CompositeModel; + steps::Integer=1, + constants=PlantMeteo.Constants(), + outputs=:none, + performance::Bool=false, +) + performance_counters = performance ? RuntimePerformanceCounters() : nothing + initial_composite_started_at = + _runtime_performance_start(performance_counters) + started_at = initial_composite_started_at + compiled = refresh_bindings!( + model; + performance=performance_counters, + ) + _runtime_performance_finish!( + performance_counters, + :initial_binding_compile, + started_at, + ) + _runtime_performance_count!( + performance_counters, + :initial_status_views_constructed, + length(compiled.status_views_by_target), + ) + _runtime_performance_count!( + performance_counters, + :initial_input_bindings_constructed, + length(compiled.input_bindings), + ) + _runtime_performance_count!( + performance_counters, + :initial_call_bindings_constructed, + length(compiled.call_bindings), + ) + started_at = _runtime_performance_start(performance_counters) + env_bindings = refresh_environment_bindings!(model, compiled) + _runtime_performance_finish!( + performance_counters, + :initial_environment_compile, + started_at, + ) + _runtime_performance_count!( + performance_counters, + :initial_environment_bindings_constructed, + length(env_bindings.bindings), + ) + empty!(env_bindings.sample_cache) + output_requests, retain_all = _model_output_selection(outputs) + started_at = _runtime_performance_start(performance_counters) + output_retention = compile_model_output_retention( + compiled, + output_requests; + retain_all=retain_all, + ) + _runtime_performance_finish!( + performance_counters, + :initial_output_retention_compile, + started_at, + ) + temporal_streams = Dict{Tuple{Symbol,ObjectId,Symbol},Any}() + _initialize_model_output_streams!( + temporal_streams, + compiled, + output_retention, + steps, + ) + started_at = _runtime_performance_start(performance_counters) + execution_plan = compile_model_execution_plan( + compiled, + env_bindings, + temporal_streams, + output_retention, + constants, + ) + _runtime_performance_finish!( + performance_counters, + :initial_execution_plan_compile, + started_at, + ) + _runtime_performance_finish!( + performance_counters, + :initial_execution_plan_and_model_bundle_compile, + started_at, + ) + _runtime_performance_count!( + performance_counters, + :initial_execution_targets_constructed, + sum((length(batch.targets) for batch in execution_plan.batches); init=0), + ) + _runtime_performance_count!( + performance_counters, + :initial_execution_batches_constructed, + length(execution_plan.batches), + ) + started_at = _runtime_performance_start(performance_counters) + output_request_targets = _initial_output_request_targets( + model, + compiled, + output_requests, + ) + _runtime_performance_finish!( + performance_counters, + :initial_output_target_compile, + started_at, + ) + _runtime_performance_finish!( + performance_counters, + :initial_composite_compile, + initial_composite_started_at, + ) + simulation = Simulation( + model, + compiled, + env_bindings, + execution_plan, + output_retention, + temporal_streams, + output_requests, + output_request_targets, + 0, + constants, + performance_counters, + ) + 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.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.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) ? + "." : + " from application `$(request.application)`.", + ) + elseif length(candidates) > 1 + if isnothing(request.application) + manual_application_ids = _manual_call_application_ids(compiled) + root_candidates = filter( + application -> !(application.id in manual_application_ids), + candidates, + ) + length(root_candidates) == 1 && return only(root_candidates) + end + 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) + return _criteria_get(selector_criteria, :scale, 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_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] + 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, + scale=target.scale, + samples=vcat( + [(target.start_time, target.initial)], + get( + sim.temporal_streams, + (application.id, object_id, request.var), + Tuple{Float64,typeof(target.initial)}[], + ), + ), + ) + for (object_id, target) in requested_objects + ] + 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 = request.policy isa HoldLast ? + sim.current_step : + 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] + is_current_target = + haskey(sim.model.registry.objects, row.object_id) && + _selector_matches_object_id( + sim.model, + request.selector, + row.object_id; + context=request.context, + ) + last_sample_time = request.policy isa HoldLast && + is_current_target ? + float(sim.current_step) : + last(row.samples)[1] + first_sample_time <= float(time) <= last_sample_time || continue + push!( + rows, + ( + timestep=time, + time=float(time), + scale=isnothing(declared_scale) ? + row.scale : + 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) + started_at = _runtime_performance_start(sim.performance) + collected = isempty(sim.output_requests) ? + _materialize_model_output_rows(_model_output_rows(sim), sink) : + _collect_model_requested_outputs(sim, sink) + _runtime_performance_finish!( + sim.performance, + :output_collection, + started_at, + ) + _runtime_performance_count!(sim.performance, :output_collections) + return collected +end + +function collect_outputs(sim::Simulation, name::Symbol; sink=DataFrames.DataFrame) + started_at = _runtime_performance_start(sim.performance) + 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) + collected = _materialize_model_output_rows( + _model_requested_output_rows(sim, request), + sink, + ) + _runtime_performance_finish!( + sim.performance, + :output_collection, + started_at, + ) + _runtime_performance_count!(sim.performance, :output_collections) + return collected +end + +function collect_outputs(sim::Simulation, object_id, variable::Symbol; sink=DataFrames.DataFrame) + started_at = _runtime_performance_start(sim.performance) + collected = _materialize_model_output_rows( + _model_output_rows(sim, object_id, variable), + sink, + ) + _runtime_performance_finish!( + sim.performance, + :output_collection, + started_at, + ) + _runtime_performance_count!(sim.performance, :output_collections) + return collected +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..056253a76 --- /dev/null +++ b/src/composite_model/scenario_dsl.jl @@ -0,0 +1,168 @@ +""" + ObjectAddress(selector) + +Return the normalized, structured diagnostic address for an object selector. +The address preserves scope, object labels, producer/callee routing, temporal +policy/window, live-status ordering, and multiplicity. +""" +struct ObjectAddress{SC,K,SP,S,N,P,A,V,R,POL,W,FS,AF,M} + scope::SC + kind::K + species::SP + scale::S + name::N + process::P + application::A + var::V + relation::R + policy::POL + window::W + from_status::FS + after::AF + multiplicity::M +end + +function ObjectAddress(selector::AbstractObjectMultiplicity) + c = criteria(selector) + scope = _criteria_scope(c) + kind = _criteria_value(c, :kind) + species = _criteria_value(c, :species) + scale = _criteria_value(c, :scale) + name = haskey(c, :name) ? c.name : nothing + process = haskey(c, :process) ? c.process : nothing + application = haskey(c, :application) ? c.application : nothing + var = haskey(c, :var) ? c.var : nothing + relation = _criteria_value(c, :relation, Relation) + policy = haskey(c, :policy) ? c.policy : nothing + window = haskey(c, :window) ? c.window : nothing + from_status = haskey(c, :from_status) ? c.from_status : false + after = haskey(c, :after) ? c.after : nothing + return ObjectAddress( + scope, + kind, + species, + scale, + name, + process, + application, + var, + relation, + policy, + window, + from_status, + after, + multiplicity(selector), + ) +end + +""" + object_address(selector) + +Return an [`ObjectAddress`](@ref) containing every normalized selector field. +""" +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 `ModelSpec(...; inputs=...)` or ", + "`ModelSpec(...; 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 `ModelSpec` 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..195f6a48a --- /dev/null +++ b/src/composite_model/selectors.jl @@ -0,0 +1,1023 @@ +struct SceneScope <: AbstractObjectSelector end + +""" + Self() + +Select the current object: the object on which the consuming model application +runs. `Self()` means a plant only when that object is itself the plant. +""" +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 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_SELECTOR_KEYWORD_FIELDS = ( + :within, + :kind, + :species, + :scale, + :name, + :relation, + :process, + :application, + :var, + :policy, + :window, + :from_status, + :after, +) +const _OBJECT_LABEL_SYMBOL_FIELDS = (:kind, :species, :scale, :name) +const _OBJECT_ROUTING_SYMBOL_FIELDS = (:process, :var, :application) +const _OBJECT_SELECTOR_POSITIONAL_TYPES = + Union{SceneScope,Self,Subtree,SelfPlant,Ancestor,Scope,Relation} +const _OBJECT_SELECTOR_FIELDS = (:within, :kind, :species, :scale, :name, :relation) +const _APPLICATION_TARGET_SELECTOR_FIELDS = (:within, :kind, :species, :scale, :name) +const _INPUT_SELECTOR_FIELDS = ( + _OBJECT_SELECTOR_FIELDS..., + :process, + :application, + :var, + :policy, + :window, + :from_status, + :after, +) +const _CALL_SELECTOR_FIELDS = ( + _OBJECT_SELECTOR_FIELDS..., + :process, + :application, +) + +function _selector_context_fields(context::Symbol) + context == :application_target && return _APPLICATION_TARGET_SELECTOR_FIELDS + context in (:object_query, :output_request) && return _OBJECT_SELECTOR_FIELDS + context == :input && return _INPUT_SELECTOR_FIELDS + context == :call && return _CALL_SELECTOR_FIELDS + error("Unsupported selector validation context `$(context)`.") +end + +function _selector_context_description(context::Symbol) + context == :application_target && return "application-target" + context == :object_query && return "object-query" + context == :output_request && return "output-request" + context == :input && return "input-binding" + context == :call && return "call-binding" + return string(context) +end + +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 == :within && begin + value isa Union{SceneScope,Self,Subtree,SelfPlant,Ancestor,Scope} || error( + "Selector keyword `within` must be a topology scope such as `Self()`, ", + "`SelfPlant()`, `SceneScope()`, `Ancestor(...)`, or `Scope(...)`; ", + "got `$(repr(value))`." + ) + return value + end + key == :relation && return Relation(value).relation + key == :after && begin + values = value isa Union{Tuple,AbstractVector} ? value : (value,) + isempty(values) && error("Selector keyword `after` cannot be empty.") + return Tuple(Symbol(item) for item in values) + end + key == :from_status && begin + value isa Bool || error( + "Selector keyword `from_status` must be `true` or `false`, got `$(repr(value))`." + ) + return value + end + key in _OBJECT_LABEL_SYMBOL_FIELDS && return _maybe_symbol_collection(value) + key in _OBJECT_ROUTING_SYMBOL_FIELDS && return _maybe_symbol(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) + unsupported = Symbol[key for key in keys(kwargs) if !(key in _OBJECT_SELECTOR_KEYWORD_FIELDS)] + isempty(unsupported) || error( + "Unsupported object selector keyword(s) `$(Tuple(unsupported))`. ", + "Supported keywords are `$(_OBJECT_SELECTOR_KEYWORD_FIELDS)`." + ) + 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 _OBJECT_SELECTOR_POSITIONAL_TYPES, selectors) || error( + "Object selector positional arguments are reserved for topology selectors such as ", + "`Self()`, `Ancestor(...)`, `Scope(...)`, or `Relation(...)`. ", + "Use keyword criteria such as `kind=:plant` and `scale=:Leaf` for labels." + ) + 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 + +function _selector_semantic_fields(selector::AbstractObjectMultiplicity) + selector_criteria = criteria(selector) + fields = Symbol[Symbol(key) for key in keys(selector_criteria) if Symbol(key) != :selectors] + for atom in selector_criteria.selectors + atom isa Relation ? push!(fields, :relation) : push!(fields, :within) + end + return unique!(fields) +end + +function _validate_selector_context( + selector::AbstractObjectMultiplicity, + context::Symbol, +) + allowed = _selector_context_fields(context) + invalid = Symbol[field for field in _selector_semantic_fields(selector) if !(field in allowed)] + isempty(invalid) || error( + "Selector field(s) `$(Tuple(invalid))` are not valid in ", + "$(_selector_context_description(context)) selectors. ", + "Allowed fields are `$(allowed)`." + ) + if context == :application_target + scope = _criteria_scope(criteria(selector)) + scope isa Union{Nothing,SceneScope,Scope} || error( + "Application-target selectors have no current object, so `within=$(repr(scope))` ", + "cannot be resolved. Use `SceneScope()` or a named `Scope(...)`; ", + "use object-relative scopes in `inputs` and `calls`." + ) + end + return selector +end + +function _validate_selector_context(selector, context::Symbol) + error( + "$(_selector_context_description(context)) selectors must use `One(...)`, ", + "`OptionalOne(...)`, or `Many(...)`; got `$(typeof(selector))`." + ) +end + +_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_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( + index -> + Symbol(key) == + _mounted_application_name(specs[index], index), + eachindex(specs), + ) + isempty(matches) && error( + "Override `$(key)` for instance `$(instance.name)` does not match a template application name." + ) + 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(_input_schema(model)))), + outputs=Tuple(Symbol.(keys(outputs_(model)))), + environment_inputs=Tuple(Symbol.(keys(environment_inputs_(model)))), + environment_outputs=Tuple(Symbol.(keys(environment_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_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( + index -> + override.application == + _mounted_application_name(specs[index], index), + eachindex(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." + ) + 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_updates(updates, instance_name::Symbol, base_names) + return Tuple( + Updates( + update.variables...; + after=Tuple( + label in base_names ? Symbol(instance_name, "__", label) : label + for label in update.after + ), + ) + for update in updates + ) +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 `ModelSpec(...; on=...)` 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_updates = _mount_updates(updates(spec), instance.name, base_names) + 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, + _replace_model_spec( + spec; + model=mounted_model, + name=mounted_name, + on=mounted_target, + inputs=mounted_inputs, + calls=mounted_calls, + updates=mounted_updates, + ), + ) + 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 _append_descendant_ids!( + ids::Vector{ObjectId}, + model::CompositeModel, + root_id::ObjectId, +) + push!(ids, root_id) + object = _model_object(model, root_id) + for child_id in object.children + _append_descendant_ids!(ids, model, child_id) + end + return ids +end + +function _descendant_ids(model::CompositeModel, root_id::ObjectId) + return _append_descendant_ids!(ObjectId[], model, root_id) +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{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 + +_criteria_value(criteria, key::Symbol) = + haskey(criteria, key) ? getproperty(criteria, key) : nothing + +function _criteria_scope(criteria) + positional = _selector_scope_from_positional(criteria.selectors) + keyword = haskey(criteria, :within) ? criteria.within : nothing + if !isnothing(positional) && !isnothing(keyword) && positional != 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) + return root_id in _object_ancestor_ids(model.registry, object_id) +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) + kind = _criteria_value(criteria_, :kind) + species = _criteria_value(criteria_, :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) + kind = _criteria_value(criteria_, :kind) + species = _criteria_value(criteria_, :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) + _validate_selector_context(selector, :object_query) + 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) + kind = _criteria_value(criteria_, :kind) + species = _criteria_value(criteria_, :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..8b3bd4a0b 100644 --- a/src/evaluation/fit.jl +++ b/src/evaluation/fit.jl @@ -14,7 +14,11 @@ For example the method for fitting the `Beer` model from the example script (see this: ```julia -function PlantSimEngine.fit(::Type{Beer}, df; J_to_umol=PlantMeteo.Constants().J_to_umol) +function PlantSimEngine.Evaluation.fit( + ::Type{Beer}, + df; + J_to_umol=PlantMeteo.Constants().J_to_umol, +) k = Statistics.mean(log.(df.Ri_PAR_f ./ (df.aPPFD ./ J_to_umol)) ./ df.LAI) return (k=k,) end @@ -28,16 +32,35 @@ and `Ri_PAR_f`. ```julia # Including example processes and models: using PlantSimEngine.Examples; +using PlantSimEngine.Evaluation; -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]) -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, +) +simulation = run!(model) +leaf = final_state(simulation, One(scale=:Leaf)) +df = DataFrame( + aPPFD=leaf.aPPFD, + LAI=leaf.LAI, + Ri_PAR_f=meteo.Ri_PAR_f[1], +) +Evaluation.fit(Beer, df) ``` Note that this is a dummy example to show that the fitting method works, as we simulate the aPPFD 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/evaluation/statistics.jl b/src/evaluation/statistics.jl index b71d0aadc..4d0b43724 100644 --- a/src/evaluation/statistics.jl +++ b/src/evaluation/statistics.jl @@ -9,7 +9,7 @@ The closer to 0 the better. # Examples ```@example -using PlantSimEngine +using PlantSimEngine.Evaluation obs = [1.0, 2.0, 3.0] sim = [1.1, 2.1, 3.1] @@ -30,7 +30,7 @@ Normalization is performed using division by observations range (max-min). # Examples ```@example -using PlantSimEngine +using PlantSimEngine.Evaluation obs = [1.0, 2.0, 3.0] sim = [1.1, 2.1, 3.1] @@ -53,7 +53,7 @@ The closer to 1 the better. # Examples ```@example -using PlantSimEngine +using PlantSimEngine.Evaluation obs = [1.0, 2.0, 3.0] sim = [1.1, 2.1, 3.1] @@ -78,7 +78,7 @@ The closer to 1 the better. # Examples ```@example -using PlantSimEngine +using PlantSimEngine.Evaluation obs = [1.0, 2.0, 3.0] sim = [1.1, 2.1, 3.1] diff --git a/src/examples_import.jl b/src/examples_import.jl index be89fc4d3..2eb4058db 100644 --- a/src/examples_import.jl +++ b/src/examples_import.jl @@ -33,16 +33,18 @@ 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")) +include(joinpath(@__DIR__, "../examples/ToySpatialEnvironment.jl")) +include(joinpath(@__DIR__, "../examples/ToyAdvancedControl.jl")) +include(joinpath(@__DIR__, "../examples/ToyModelDeveloper.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,16 +84,18 @@ 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: export Beer, ToyLightPartitioningModel, ToyLAIModel, ToyLAIfromLeafAreaModel, ToyLeafSurfaceModel, ToyPlantLeafSurfaceModel, ToyDegreeDaysCumulModel export ToyAssimModel, ToyCAllocationModel, ToyCDemandModel, ToySoilWaterModel export ToyAssimGrowthModel, ToyRUEGrowthModel, ToyMaintenanceRespirationModel, ToyPlantRmModel, ToyCBiomassModel +export ToySpatialEnvironment, ToyEnvironmentHandle +export ToyEnvironmentReaderModel, ToyEnvironmentControllerModel +export ToySelectiveCallControllerModel, ToyStockWriterModel +export ToyDevelopmentModel, ToyDailyDevelopmentModel 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/input_schema.jl b/src/input_schema.jl new file mode 100644 index 000000000..506b6f302 --- /dev/null +++ b/src/input_schema.jl @@ -0,0 +1,84 @@ +""" + Required(T) + +Declare a status input that must be supplied by object state or resolved from +another model application. + +`T` is an expected type, not an initialization value. It may be abstract or +parametric so model declarations remain generic. +""" +struct Required{T} end + +Required(T::Type) = Required{T}() + +""" + Default(value) + +Declare a status input with a genuine model-provided default. The compiler adds +`value` to a target object's status only when the variable is neither supplied +by the user nor already present. +""" +struct Default{T} + value::T +end + +_is_input_declaration(value) = value isa Union{Required,Default} +@inline _has_only_input_declarations(::Tuple{}) = true +@inline function _has_only_input_declarations(declarations::Tuple) + return _is_input_declaration(first(declarations)) && + _has_only_input_declarations(Base.tail(declarations)) +end + +_input_expected_type(::Required{T}) where {T} = T +_input_expected_type(declaration::Default) = typeof(declaration.value) + +_has_input_default(::Required) = false +_has_input_default(::Default) = true + +function _input_default(declaration::Default) + return declaration.value +end + +_private_initial_value(value) = deepcopy(value) + +@noinline function _invalid_input_schema_error(model, schema) + context = "`inputs_($(typeof(model)))`" + schema isa NamedTuple || error( + context, + " must return a `NamedTuple` of `Required(T)` and `Default(value)` declarations; ", + "got `$(typeof(schema))`.", + ) + invalid = Pair{Symbol,Any}[ + Symbol(name) => value + for (name, value) in pairs(schema) + if !_is_input_declaration(value) + ] + isempty(invalid) || error( + context, + " must make every input explicit with `Required(T)` or `Default(value)`. ", + "Invalid declaration(s): ", + join( + ["`$(name)=$(repr(value))`" for (name, value) in invalid], + ", ", + ), + ".", + ) + error("Invalid input schema.") +end + +@inline function _input_schema(model) + schema = inputs_(model) + schema isa NamedTuple || return _invalid_input_schema_error(model, schema) + _has_only_input_declarations(values(schema)) || + return _invalid_input_schema_error(model, schema) + return schema +end + +function _input_default_values(schema::NamedTuple) + pairs_ = Pair{Symbol,Any}[] + for (name, declaration) in pairs(schema) + declaration isa Default || continue + push!(pairs_, Symbol(name) => declaration.value) + end + return (; pairs_...) +end diff --git a/src/model_discovery.jl b/src/model_discovery.jl new file mode 100644 index 000000000..0b24a9938 --- /dev/null +++ b/src/model_discovery.jl @@ -0,0 +1,331 @@ +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_input_descriptor(T), + "outputs" => _model_var_descriptor(T, outputs_), + "environmentInputs" => _model_var_descriptor(T, environment_inputs_), + "environmentOutputs" => _model_var_descriptor(T, environment_outputs_), + "timespec" => _safe_string_trait(T, timespec), + "outputPolicy" => _safe_string_trait(T, output_policy), + "timestepHint" => _safe_string_trait(T, timestep_hint), + "environmentHint" => _safe_string_trait(T, environment_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 _model_input_descriptor(::Type{T}) where {T<:AbstractModel} + instance = _try_zero_arg_model(T) + isnothing(instance) && (instance = _try_dummy_model(T)) + isnothing(instance) && return Dict{String,Any}() + schema = try + _input_schema(instance) + catch err + return Dict{String,Any}("_error" => sprint(showerror, err)) + end + return Dict( + string(name) => Dict{String,Any}( + "declaration" => declaration isa Required ? "required" : "defaulted", + "expectedType" => string(_input_expected_type(declaration)), + "default" => declaration isa Default ? + _jsonable_model_value(declaration.value) : + nothing, + "defaultJulia" => declaration isa Default ? + repr(declaration.value) : + nothing, + ) + for (name, declaration) in pairs(schema) + ) +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..b9b3d5afe 100644 --- a/src/processes/models_inputs_outputs.jl +++ b/src/processes/models_inputs_outputs.jl @@ -21,7 +21,7 @@ inputs(Process1Model(1.0)) ``` """ function inputs(model::T) where {T<:AbstractModel} - keys(inputs_(model)) + keys(_input_schema(model)) end function inputs_(model::AbstractModel) @@ -59,30 +59,63 @@ 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 + """ - input_bindings(spec::ModelSpec) + application_name(spec::ModelSpec) -Optional explicit input-to-producer bindings used by multi-rate resolution. +Optional stable name for one model application in the unified composite-model/object API. +""" +application_name(spec::ModelSpec) = spec.name -`ModelSpec` is the user-facing API for mapping-level coupling configuration. -Bindings are read from `ModelSpec` during multiscale multi-rate simulation. +""" + applies_to(spec::ModelSpec) -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) +Object selector where a model application runs in the unified composite-model/object API. +""" +applies_to(spec::ModelSpec) = spec.applies_to -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`. +""" + value_inputs(spec::ModelSpec) -# Example +Unified composite-model/object value-input bindings declared with the +`ModelSpec(...; inputs=...)` keyword. +""" +value_inputs(spec::ModelSpec) = spec.inputs +input_origins(spec::ModelSpec) = spec.input_origins + +""" + model_calls(spec::ModelSpec) + +Unified composite-model/object manual call bindings declared with the +`ModelSpec(...; calls=...)` keyword. +""" +model_calls(spec::ModelSpec) = spec.calls +call_origins(spec::ModelSpec) = spec.call_origins -```julia -InputBindings(; C=(process=:myproducer, var=:S)) -``` """ -input_bindings(spec::ModelSpec) = spec.input_bindings + environment_config(spec::ModelSpec) + +Optional composite-model/object environment configuration declared with +`ModelSpec(...; environment=Environment(...))`. +""" +environment_config(spec::ModelSpec) = spec.environment """ output_routing(spec::ModelSpec) @@ -95,48 +128,62 @@ 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) + environment_bindings(spec::ModelSpec) -Optional explicit weather aggregation bindings used by multi-rate MTG runtime. -Each key is the target meteo variable exposed to the model at execution time. +Optional explicit weather aggregation bindings used by the model runtime. +Each key is the target environment variable exposed to the model at execution time. Each value can be: - PlantMeteo reducer instance/type (e.g. `MeanWeighted()`, `MaxReducer`) - `Function`: custom reducer callable - `NamedTuple`: optional fields `source` and `reducer` """ -meteo_bindings(spec::ModelSpec) = spec.meteo_bindings +environment_bindings(spec::ModelSpec) = spec.environment_bindings """ - meteo_window(spec::ModelSpec) + environment_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 +environment_window(spec::ModelSpec) = spec.environment_window """ - inputs(mapping::ModelMapping) - inputs(mapping::AbstractDict{Symbol,T}) + environment_inputs(model::AbstractModel) + environment_inputs_(model::AbstractModel) + +Environment variables read directly by a model. -Get the inputs of the models in a mapping, for each process and organ type. +This trait is separate from `inputs_` because meteorology may be constant, +table-backed, or produced by a microclimate backend. The default is empty. """ -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 +environment_inputs(model::AbstractModel) = keys(environment_inputs_(model)) +environment_inputs(spec::ModelSpec) = keys(environment_inputs_(spec)) +environment_inputs_(model::AbstractModel) = NamedTuple() +environment_inputs_(model::Missing) = NamedTuple() +""" + environment_outputs(model::AbstractModel) + environment_outputs_(model::AbstractModel) + +Environment variables that a controller model is allowed to +commit with [`commit_environment!`](@ref), for example local microclimate +variables computed over a canopy, voxel, or octree backend. + +These declarations are environment capabilities, not status outputs. +Declare diagnostic status values separately with `outputs_`. +""" +environment_outputs(model::AbstractModel) = keys(environment_outputs_(model)) +environment_outputs(spec::ModelSpec) = keys(environment_outputs_(spec)) +environment_outputs_(model::AbstractModel) = NamedTuple() +environment_outputs_(model::Missing) = NamedTuple() """ outputs(model::AbstractModel) @@ -168,22 +215,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 @@ -197,8 +228,9 @@ end variables(model) variables(model, models...) -Returns a tuple with the name of the variables needed by a model, or a union of those -variables for several models. +Return the values PlantSimEngine can initialize without user input: genuine +input defaults declared with `Default(value)` and initial output-state values. +Required inputs have no initialization value and are therefore omitted. # Note @@ -217,7 +249,7 @@ variables(Process1Model(1.0), Process2Model()) # output -(var1 = -Inf, var2 = -Inf, var3 = -Inf, var4 = -Inf, var5 = -Inf) +(var3 = -Inf, var4 = -Inf, var5 = -Inf) ``` # See also @@ -225,17 +257,13 @@ variables(Process1Model(1.0), Process2Model()) [`inputs`](@ref), [`outputs`](@ref) and [`variables_typed`](@ref) """ 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)) + if length((ms...,)) > 0 + return merge(variables(m), variables(ms...)) + end + input_defaults = m isa Missing ? + NamedTuple() : + _input_default_values(_input_schema(m)) + return merge(input_defaults, outputs_(m)) end """ @@ -265,20 +293,13 @@ 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 genuine input defaults and initial output-state values +declared by `model`. Inputs declared with `Required(T)` are omitted. """ -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) @@ -311,8 +332,11 @@ PlantSimEngine.variables_typed(Process1Model(1.0), Process2Model()) """ function variables_typed(m::T) where {T<:AbstractModel} - in_vars = inputs_(m) - in_vars_type = Dict(zip(keys(in_vars), typeof(in_vars).types)) + in_vars = _input_schema(m) + in_vars_type = Dict( + Symbol(name) => _input_expected_type(declaration) + for (name, declaration) in pairs(in_vars) + ) out_vars = outputs_(m) out_vars_type = Dict(zip(keys(out_vars), typeof(out_vars).types)) diff --git a/src/processes/process_generation.jl b/src/processes/process_generation.jl index 9bd1e90e8..ed15faeb0 100644 --- a/src/processes/process_generation.jl +++ b/src/processes/process_generation.jl @@ -111,7 +111,7 @@ macro process(f, args...) We also have to define the model inputs and outputs by adding methods to `inputs_`: ```julia - PlantSimEngine.inputs_(::$(dummy_type_name)) = (X=-Inf,) + PlantSimEngine.inputs_(::$(dummy_type_name)) = (X=Required(Float64),) ``` And `outputs_` from PlantSimEngine: @@ -124,56 +124,43 @@ 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!`: ```julia function PlantSimEngine.run!( - ::$(dummy_type_name), - models, + m::$(dummy_type_name), status, - meteo, + environment, constants, - extra + context ) - status.Y = model.$(process_name).a * meteo.CO2 + status.X - run!(model.other_process_name, models, status, meteo, constants, extra) + status.Y = m.a * environment.CO2 + status.X + run_call!(context, :other_process_name; publish=true) end ``` - Note that {#8abeff}run!(){/#8abeff} takes six arguments: the model type (used for dispatch), the ModelMapping, the status, the meteorology, - the constants and any extra values. + Note that {#8abeff}run!(){/#8abeff} takes five arguments: the model type + (used for dispatch and parameter access), the status, the sampled + environment, constants, and runtime context. - 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 process name), and meteorology variables. + Then we can use variables from the status as inputs or outputs, read + this model's parameters directly from `m`, and use sampled environment + 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!(context, :other_process_name; 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 - 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 - process name), and meteorology variables. + The model argument owns parameters, `status` owns bound state, + `environment` contains sampled forcing, and `context` exposes hard + calls and lifecycle operations. """ ) ) 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..6da46cb20 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`." - ) - sec = float(Dates.value(Dates.Second(p))) - sec > 0.0 || error("Invalid duration `$(p)`: expected a strictly positive period.") - return sec -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 - end - return nothing -end +timestep_hint(model::AbstractModel) = timestep_hint(typeof(model)) +timestep_hint(::Type{<:AbstractModel}) = nothing -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) -end +environment_hint(model::AbstractModel) = environment_hint(typeof(model)) +environment_hint(::Type{<:AbstractModel}) = nothing -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." - ) - return step +struct _ResolvedTimeStepHint + fixed::Union{Nothing,Dates.FixedPeriod} + range::Union{Nothing,Tuple{Dates.FixedPeriod,Dates.FixedPeriod}} + preferred::Union{Nothing,Symbol,Dates.FixedPeriod} 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`." - ) - return state[1] -end +_seconds_from_period(period::Dates.FixedPeriod) = + float(Dates.value(Dates.Millisecond(period))) * 1.0e-3 -function _base_step_seconds_from_meteo_row(row; require_duration::Bool=false, context::String="meteo") - 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`." +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." ) - elseif require_duration - error( - "Missing required `duration` in $(context). ", - "Meteorology must define a valid per-row duration." + 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 - return 1.0 + error( + "Invalid timestep_hint for $(scale)/$(process): expected a fixed period or period range." + ) 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() - for (i, row) in enumerate(Tables.rows(meteo)) - _base_step_seconds_from_meteo_row(row; require_duration=true, context="meteo row $(i)") +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 nothing + return _ResolvedTimeStepHint(fixed, range, preferred) end - - if DataFormat(meteo) == SingletonAlike() && hasproperty(meteo, :duration) - _base_step_seconds_from_meteo_row(meteo; require_duration=true, context="meteo") - return nothing - end - - # Unknown formats are validated later by run-path specific checks. - return nothing + error("Invalid timestep_hint for $(scale)/$(process).") 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")) - end - return TimelineContext(1.0) +function _normalize_environment_hint(scale::Symbol, process::Symbol, hint) + isnothing(hint) && return (bindings=nothing, window=nothing) + hint isa NamedTuple || error( + "Invalid environment_hint for $(scale)/$(process): expected a NamedTuple." + ) + all(key -> key in (:bindings, :window), keys(hint)) || error( + "Invalid environment_hint fields for $(scale)/$(process)." + ) + bindings = + haskey(hint, :bindings) ? _normalize_environment_bindings(hint.bindings) : nothing + window = haskey(hint, :window) ? _normalize_environment_window(hint.window) : nothing + return (bindings=bindings, window=window) 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 +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 -""" - _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)) +function _is_default_clock(clock::ClockSpec) + 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 -struct EffectiveRateSummary - base_step_seconds::Float64 - rates::Dict{Float64,Vector{NamedTuple}} +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 steps 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 +function _first_table_row(table; context::String="environment") + state = iterate(Tables.rows(table)) + isnothing(state) && error("Cannot infer a timestep from an empty $(context) table.") + return state[1] 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) +function _base_step_seconds_from_environment_row( + row; + require_duration::Bool=false, + context::String="environment", +) + if hasproperty(row, :duration) + 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).") end - - return EffectiveRateSummary(timeline.base_step_seconds, grouped) + return 1.0 end -function _resolve_meteo_hint_clock(scale::Symbol, process::Symbol, model, timeline::TimelineContext) - base_sec = 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 - 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, - "`." +function _validate_environment_duration(environment) + isnothing(environment) && return nothing + if environment isa Atmosphere + _base_step_seconds_from_environment_row(environment; require_duration=true) + elseif environment isa TimeStepTable || DataFormat(environment) == TableAlike() + base_seconds = nothing + for (i, row) in enumerate(Tables.rows(environment)) + seconds = _base_step_seconds_from_environment_row( + row; + require_duration=true, + context="environment 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 environment 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 + elseif DataFormat(environment) == SingletonAlike() && hasproperty(environment, :duration) + _base_step_seconds_from_environment_row(environment; require_duration=true) end - - dt = desired_sec / base_sec - return ClockSpec(dt, 0.0), reason + return nothing 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 +function _timeline_context(environment) + if environment isa TimeStepTable || (!isnothing(environment) && DataFormat(environment) == TableAlike()) + row = _first_table_row(environment) + return TimelineContext( + _base_step_seconds_from_environment_row(row; require_duration=true) + ) + elseif environment isa Atmosphere || + (!isnothing(environment) && DataFormat(environment) == SingletonAlike() && + hasproperty(environment, :duration)) + return TimelineContext( + _base_step_seconds_from_environment_row(environment; require_duration=true) + ) end - - return rows + return TimelineContext(1.0) 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) +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 -function _effective_multirate(mapping::ModelMapping{MultiScale}, meteo) - is_multirate(mapping) && return true - return _mapping_requires_runtime_multirate(mapping, meteo) +function _model_clock(model_spec, model, timeline::TimelineContext) + 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 _runtime_clock_source_for_spec(spec::ModelSpec) !isnothing(timestep(spec)) && return :modelspec - return _is_default_clock(timespec(model_(spec))) ? :meteo_base_step : :model_timespec + return _is_default_clock(timespec(model_(spec))) ? :environment_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 +function _resolve_environment_hint_clock( + scale::Symbol, + process::Symbol, + model, + timeline::TimelineContext, +) + base_seconds = timeline.base_step_seconds + hint = _normalize_timestep_hint(scale, process, timestep_hint(model)) + reason = nothing + if !isnothing(hint.fixed) + required = _seconds_from_period(hint.fixed) + isapprox(required, base_seconds; atol=1.0e-9, rtol=0.0) || + (reason = "Environment base step is outside `timestep_hint.required=$(hint.fixed)` for `$(scale)/$(process)`.") + elseif !isnothing(hint.range) + lower, upper = _seconds_from_period.(hint.range) + lower <= base_seconds <= upper || + (reason = "Environment 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..799674377 --- /dev/null +++ b/src/time/runtime/environment_backends.jl @@ -0,0 +1,445 @@ +""" + AbstractEnvironmentBackend + +Backend protocol for meteorology and mutable microclimate providers. + +PlantSimEngine defines the protocol, not the spatial indexing strategy. External +packages subtype `PlantSimEngine.EnvironmentAPI.AbstractEnvironmentBackend` +and extend the functions in `PlantSimEngine.EnvironmentAPI`, including +`sample`, `commit_environment!`, `update_index!`, `get_nsteps`, and +`base_step_seconds`. +""" +abstract type AbstractEnvironmentBackend end + +""" + EnvironmentContext(application, object_id, scale, process) + +Immutable model-target metadata passed to [`bind_environment`](@ref). Backends +return an opaque handle from that method and use the handle for all subsequent +sampling and commits. Runtime status and geometry are intentionally absent: +object-to-environment routing belongs to the compiled handle. +""" +struct EnvironmentContext{O} + application::Symbol + object_id::O + scale::Symbol + process::Symbol +end + +""" + GlobalConstant(source) + +Environment backend that preserves the current PlantSimEngine behavior: every +model receives the same source object or source row at a given timestep. +""" +struct GlobalConstant{M} <: AbstractEnvironmentBackend + source::M +end + +environment_source(backend::GlobalConstant) = backend.source + +""" + environment_backend(environment_or_backend) + +Return an environment backend. Plain environment data is wrapped in +`GlobalConstant`; existing environment backends are returned unchanged. +""" +environment_backend(backend::AbstractEnvironmentBackend) = backend +environment_backend(source) = GlobalConstant(source) + +""" + 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.EnvironmentAPI.base_step_seconds(backend)`." + ) +end + +function base_step_seconds(backend::GlobalConstant) + return _timeline_context(environment_source(backend)).base_step_seconds +end + +get_nsteps(backend::AbstractEnvironmentBackend) = error( + "Environment backend `$(typeof(backend))` must implement ", + "`PlantSimEngine.EnvironmentAPI.get_nsteps(backend)`." +) +get_nsteps(backend::GlobalConstant) = isnothing(environment_source(backend)) ? 1 : get_nsteps(environment_source(backend)) + +function _validate_environment_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_environment_duration(backend::GlobalConstant) = _validate_environment_duration(environment_source(backend)) + +_timeline_context(backend::AbstractEnvironmentBackend) = TimelineContext(float(base_step_seconds(backend))) +_timeline_context(backend::GlobalConstant) = _timeline_context(environment_source(backend)) + +function _environment_row_at_step(source, i::Int) + isnothing(source) && return nothing + if source isa TimeStepTable || DataFormat(source) == TableAlike() + rows = Tables.rows(source) + applicable(getindex, rows, i) && return rows[i] + return first(Iterators.drop(rows, i - 1)) + end + return source +end + +@inline _environment_row_at_step(source::DataFrames.DataFrame, i::Int) = + DataFrames.DataFrameRow(source, i, :) + +function _available_environment_variables(source) + row = _first_environment_row(source) + 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) + source = environment_source(backend) + isnothing(source) && return Set{Symbol}() + return _available_environment_variables(source) +end + +function validate_environment_inputs(model_specs::Dict{Symbol,Dict{Symbol,ModelSpec}}, backend::GlobalConstant) + return invoke( + validate_environment_inputs, + Tuple{Dict{Symbol,Dict{Symbol,ModelSpec}},AbstractEnvironmentBackend}, + model_specs, + backend, + ) +end + +function validate_environment_inputs(model_specs::Dict{Symbol,Dict{Symbol,ModelSpec}}, backend::AbstractEnvironmentBackend) + available = environment_variables(backend) + isnothing(available) && return nothing + + missing_rows = _collect_missing_environment_rows(model_specs, var -> var in available) + return _error_missing_environment_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_environment_inputs(model::CompositeModel) + validate_environment_inputs(compiled::CompiledCompositeModel) + validate_environment_inputs(compiled::CompiledCompositeModel, environment_or_backend) + +Validate declared composite-model/object `environment_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 environment backend. Diagnostics report model +application ids, so several applications for the same process can be diagnosed +independently. +""" +function validate_environment_inputs(compiled::CompiledCompositeModel, environment_or_backend) + backend = environment_backend(environment_or_backend) + return validate_environment_inputs(_model_model_specs_by_application(compiled), backend) +end + +function validate_environment_inputs(compiled::CompiledCompositeModel) + refresh_environment_bindings!(compiled.model, compiled) + return nothing +end + +function validate_environment_inputs(model::CompositeModel) + compiled = refresh_bindings!(model) + return validate_environment_inputs(compiled) +end + +function validate_environment_inputs(model::CompositeModel, environment_or_backend) + compiled = refresh_bindings!(model) + return validate_environment_inputs(compiled, environment_or_backend) +end + +""" + sample(backend, handle, variable, time) + sample(backend, handle, state, variable, time) + +Sample one environmental variable through an opaque compiled backend `handle`. +The four-argument method reads the backend's committed state. The five-argument +method reads a transient backend-specific `state` supplied through +`run_call!(context, name; environment=state)`. +""" +function sample(backend::AbstractEnvironmentBackend, handle, variable::Symbol, time) + error( + "Environment backend `$(typeof(backend))` must implement ", + "`PlantSimEngine.sample(backend, handle, variable, time)`." + ) +end + +function sample(backend::AbstractEnvironmentBackend, handle, state, variable::Symbol, time) + error( + "Environment backend `$(typeof(backend))` must implement transient sampling with ", + "`PlantSimEngine.sample(backend, handle, state, variable, time)` for state ", + "`$(typeof(state))`." + ) +end + +function sample(backend::GlobalConstant, handle, variable::Symbol, time) + source = _environment_row_at_step(environment_source(backend), Int(round(time))) + isnothing(source) && return nothing + hasproperty(source, variable) || error( + "GlobalConstant source does not provide variable `$(variable)`." + ) + return getproperty(source, variable) +end + +function sample(backend::GlobalConstant, handle, state, variable::Symbol, time) + source = _environment_row_at_step(state, Int(round(time))) + isnothing(source) && return nothing + hasproperty(source, variable) || error( + "Transient GlobalConstant state does not provide variable `$(variable)`." + ) + return getproperty(source, variable) +end + +""" + commit_environment!(backend, handle, state, time) + +Commit an accepted environment `state` through an opaque compiled backend +`handle`. Model kernels call `commit_environment!(context, state)`; backend +authors implement this method for their concrete mutable environment. +""" +function commit_environment!(backend::AbstractEnvironmentBackend, handle, state, time) + error( + "Environment backend `$(typeof(backend))` does not implement ", + "`PlantSimEngine.commit_environment!(backend, handle, state, time)`." + ) +end + +commit_environment!(backend::GlobalConstant, handle, state, time) = error( + "GlobalConstant is immutable and cannot receive an accepted environment commit." +) + +""" + update_index!(backend, changed_entities, removed_object_ids) + +Update the backend spatial/entity index after topology or geometry changes. +`changed_entities` contains only new or changed objects and +`removed_object_ids` contains stable [`ObjectId`](@ref)s that no longer exist. +The initial compilation supplies every entity and an empty removal vector. +""" +update_index!( + ::AbstractEnvironmentBackend, + changed_entities, + removed_object_ids, +) = nothing +update_index!(::GlobalConstant, changed_entities, removed_object_ids) = + nothing + +""" + sample_environment(backend, handle, time, variables) + sample_environment(backend, handle, state, time, variables) + +Sample a model-facing source row through a compiled backend `handle`. +`GlobalConstant` returns the original row; other backends return a `NamedTuple` +assembled from `sample` calls. The overload containing `state` preserves the +same handle while sampling a transient environment. +""" +function sample_environment( + backend::AbstractEnvironmentBackend, + handle, + time, + variables, +) + pairs = Pair{Symbol,Any}[] + for variable in variables + push!(pairs, variable => sample(backend, handle, variable, time)) + end + return (; pairs...) +end + +function sample_environment( + backend::AbstractEnvironmentBackend, + handle, + state, + time, + variables, +) + pairs = Pair{Symbol,Any}[] + for variable in variables + push!(pairs, variable => sample(backend, handle, state, variable, time)) + end + return (; pairs...) +end + +function sample_environment( + backend::GlobalConstant, + handle, + time, + variables, +) + return _environment_row_at_step(environment_source(backend), Int(round(time))) +end + +function sample_environment( + backend::GlobalConstant, + handle, + state, + time, + variables, +) + return _environment_row_at_step(state, Int(round(time))) +end + +function _environment_sampling_rules(model_spec::ModelSpec) + bindings = environment_bindings(model_spec) + bindings = bindings isa NamedTuple ? bindings : NamedTuple() + environment_sources = _environment_source_overrides(model_spec) + rules = Pair{Symbol,Symbol}[] + for target in keys(environment_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, + handle, + time, + model_spec::ModelSpec +) + pairs = Pair{Symbol,Any}[] + for (target, source) in _environment_sampling_rules(model_spec) + push!(pairs, target => sample(backend, handle, source, time)) + end + return (; pairs...) +end + +function sample_environment( + backend::AbstractEnvironmentBackend, + handle, + state, + time, + model_spec::ModelSpec +) + pairs = Pair{Symbol,Any}[] + for (target, source) in _environment_sampling_rules(model_spec) + push!(pairs, target => sample(backend, handle, state, source, time)) + end + return (; pairs...) +end + +function _sample_global_environment_row(row, model_spec::ModelSpec) + bindings = environment_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 source is `nothing`, but the model requires source variable ", + "`$(target)`." + ) + hasproperty(row, source) || error( + "GlobalConstant source does not provide source variable `$(source)` for model-facing variable ", + "`$(target)`." + ) + push!(pairs, target => getproperty(row, source)) + end + if !isnothing(row) && hasproperty(row, :duration) + push!(pairs, :duration => getproperty(row, :duration)) + end + return (; pairs...) +end + +function sample_environment( + backend::GlobalConstant, + handle, + time, + model_spec::ModelSpec, +) + row = _environment_row_at_step(environment_source(backend), Int(round(time))) + return _sample_global_environment_row(row, model_spec) +end + +function sample_environment( + backend::GlobalConstant, + handle, + state, + time, + model_spec::ModelSpec, +) + row = _environment_row_at_step(state, Int(round(time))) + return _sample_global_environment_row(row, model_spec) +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/environment_sampling.jl b/src/time/runtime/environment_sampling.jl new file mode 100644 index 000000000..5d9a1d795 --- /dev/null +++ b/src/time/runtime/environment_sampling.jl @@ -0,0 +1,219 @@ +# Model-facing environment sampling backed by PlantMeteo's weather adapters. +function _has_environment_sampler_api() + return isdefined(PlantMeteo, :prepare_weather_sampler) && + isdefined(PlantMeteo, :RollingWindow) && + isdefined(PlantMeteo, :sample_weather) +end + +function _prepare_environment_sampler(environment) + !_has_environment_sampler_api() && return nothing + environment isa TimeStepTable{<:Atmosphere} || return nothing + return PlantMeteo.prepare_weather_sampler(environment) +end + +function _runtime_environment_window(window) + return _normalize_environment_window(window) +end + +function _environment_sampling_window(clock::ClockSpec, model_spec) + window = _runtime_environment_window(environment_window(model_spec)) + if isnothing(window) + return PlantMeteo.RollingWindow(float(clock.dt)) + end + # Keep historical behavior when a plain RollingWindow marker is provided: + # rolling width follows the model clock by default. + if window isa PlantMeteo.RollingWindow && window.dt == 1.0 + return PlantMeteo.RollingWindow(float(clock.dt)) + end + return window +end + +_normalize_environment_reducer(reducer) = _normalize_policy_reducer(reducer) + +function _normalize_environment_binding_rule(target::Symbol, rule) + if rule isa NamedTuple + src = haskey(rule, :source) ? Symbol(rule.source) : target + reducer = haskey(rule, :reducer) ? _normalize_environment_reducer(rule.reducer) : PlantMeteo.MeanWeighted() + return (source=src, reducer=reducer) + elseif rule isa Function || rule isa PlantMeteo.AbstractTimeReducer || rule isa DataType + return (source=target, reducer=_normalize_environment_reducer(rule)) + end + + error( + "Unsupported environment binding value `$(rule)` for target `$(target)`. ", + "Use a reducer type/instance, callable, or NamedTuple(source=..., reducer=...)." + ) +end + +function _raw_environment_requirements_for_spec(model_spec) + required = Set{Symbol}(keys(environment_inputs_(model_spec))) + isempty(required) && return required + + raw_required = Set{Symbol}() + bindings = environment_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_environment_row(environment) + isnothing(environment) && return nothing + is_table = try + DataFormat(environment) == TableAlike() + catch + false + end + if is_table + rows = Tables.rows(environment) + state = iterate(rows) + isnothing(state) && return nothing + return state[1] + end + return environment +end + +_environment_has_field(row, var::Symbol) = hasproperty(row, var) +_environment_has_field(row::NamedTuple, var::Symbol) = haskey(row, var) + +function _collect_missing_environment_rows( + model_specs::Dict{Symbol,Dict{Symbol,ModelSpec}}, + has_environment_variable, +) + missing_rows = NamedTuple[] + for (scale, specs_at_scale) in model_specs + for (process, spec) in specs_at_scale + required = _raw_environment_requirements_for_spec(spec) + missing = Symbol[ + var for var in required if !has_environment_variable(var) + ] + isempty(missing) && continue + push!(missing_rows, (scale=scale, process=process, missing=Tuple(missing))) + end + end + return missing_rows +end + +function _format_missing_environment_rows(missing_rows) + return join( + [ + string(row.scale, "/", row.process, " missing ", row.missing) + for row in missing_rows + ], + "; " + ) +end + +function _error_missing_environment_inputs(missing_rows; subject::AbstractString, noun::AbstractString, target::AbstractString) + isempty(missing_rows) && return nothing + + error( + subject, + " is missing ", + noun, + " required by model `environment_inputs_`: ", + _format_missing_environment_rows(missing_rows), + ". Add the ", + noun, + " to ", + target, + ", declare an `Environment(; sources=...)` remapping, ", + "or remove the unused environment input from the model trait." + ) +end + +""" + validate_environment_inputs(model_specs, environment) + +Validate declared `environment_inputs_` against the available environment 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 environment object. +""" +function validate_environment_inputs(model_specs::Dict{Symbol,Dict{Symbol,ModelSpec}}, environment) + row = _first_environment_row(environment) + isnothing(row) && return nothing + + missing_rows = _collect_missing_environment_rows(model_specs, var -> _environment_has_field(row, var)) + return _error_missing_environment_inputs( + missing_rows; + subject="Environment", + noun="fields", + target="environment" + ) +end + +function validate_environment_inputs(model_specs::AbstractDict{Symbol,<:AbstractDict}, environment) + 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_environment_inputs(normalized_specs, environment) +end + +function validate_environment_inputs(models::NamedTuple, environment) + specs = Dict( + :Default => Dict{Symbol,ModelSpec}( + process(model) => as_model_spec(model) for model in values(models) + ) + ) + return validate_environment_inputs(specs, environment) +end + +function _environment_transforms_for_model(model_spec) + bindings = environment_bindings(model_spec) + isnothing(bindings) && return nothing + bindings isa NamedTuple || return nothing + isempty(keys(bindings)) && return nothing + + pairs_out = Pair{Symbol,Any}[] + for (target, rule) in pairs(bindings) + push!(pairs_out, target => _normalize_environment_binding_rule(target, rule)) + end + return (; pairs_out...) +end + +function _sample_environment_for_model( + environment_sampler, + environment, + i::Int, + model_clock::ClockSpec, + model_spec +) + transforms = _environment_transforms_for_model(model_spec) + window = _runtime_environment_window(environment_window(model_spec)) + + isnothing(environment_sampler) && begin + if !isnothing(transforms) || !isnothing(window) + @warn string( + "Environment sampling rules were provided but the weather sampler API is unavailable or the source is not TimeStepTable{Atmosphere}. ", + "Falling back to raw environment rows." + ) maxlog = 1 + end + return environment + end + + # Fast-path: default 1:1 weather step with no custom transforms. + if float(model_clock.dt) <= 1.0 && + isnothing(transforms) && + (isnothing(window) || window isa PlantMeteo.RollingWindow) + return environment + end + + window = _environment_sampling_window(model_clock, model_spec) + return PlantMeteo.sample_weather(environment_sampler, i; window=window, transforms=transforms) +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 deleted file mode 100644 index 8a80f62bf..000000000 --- a/src/time/runtime/meteo_sampling.jl +++ /dev/null @@ -1,121 +0,0 @@ -function _has_meteo_sampler_api() - return isdefined(PlantMeteo, :prepare_weather_sampler) && - isdefined(PlantMeteo, :RollingWindow) && - isdefined(PlantMeteo, :sample_weather) -end - -function _prepare_meteo_sampler(meteo) - !_has_meteo_sampler_api() && return nothing - meteo isa TimeStepTable{<:Atmosphere} || return nothing - return PlantMeteo.prepare_weather_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." - ) -end - -function _meteo_sampling_window(clock::ClockSpec, model_spec) - window = _runtime_meteo_window(meteo_window(model_spec)) - if isnothing(window) - return PlantMeteo.RollingWindow(float(clock.dt)) - end - # Keep historical behavior when a plain RollingWindow marker is provided: - # rolling width follows the model clock by default. - if window isa PlantMeteo.RollingWindow && window.dt == 1.0 - return PlantMeteo.RollingWindow(float(clock.dt)) - end - 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 - -function _normalize_meteo_binding_rule(target::Symbol, rule) - if rule isa NamedTuple - src = haskey(rule, :source) ? Symbol(rule.source) : target - reducer = haskey(rule, :reducer) ? _normalize_meteo_reducer(rule.reducer) : PlantMeteo.MeanWeighted() - return (source=src, reducer=reducer) - elseif rule isa Function || rule isa PlantMeteo.AbstractTimeReducer || rule isa DataType - return (source=target, reducer=_normalize_meteo_reducer(rule)) - end - - error( - "Unsupported meteo binding value `$(rule)` for target `$(target)`. ", - "Use a reducer type/instance, callable, or NamedTuple(source=..., reducer=...)." - ) -end - -function _meteo_transforms_for_model(model_spec) - bindings = meteo_bindings(model_spec) - isnothing(bindings) && return nothing - bindings isa NamedTuple || return nothing - isempty(keys(bindings)) && return nothing - - pairs_out = Pair{Symbol,Any}[] - for (target, rule) in pairs(bindings) - push!(pairs_out, target => _normalize_meteo_binding_rule(target, rule)) - end - return (; pairs_out...) -end - -function _sample_meteo_for_model( - meteo_sampler, - meteo, - i::Int, - model_clock::ClockSpec, - model_spec -) - transforms = _meteo_transforms_for_model(model_spec) - window = _runtime_meteo_window(meteo_window(model_spec)) - - 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}. ", - "Falling back to raw meteo rows." - ) maxlog = 1 - end - return meteo - end - - # Fast-path: default 1:1 weather step with no custom transforms. - if float(model_clock.dt) <= 1.0 && - isnothing(transforms) && - (isnothing(window) || window isa PlantMeteo.RollingWindow) - return meteo - end - - window = _meteo_sampling_window(model_clock, model_spec) - return PlantMeteo.sample_weather(meteo_sampler, i; window=window, transforms=transforms) -end diff --git a/src/time/runtime/output_export.jl b/src/time/runtime/output_export.jl index 1565a83db..a079f692d 100644 --- a/src/time/runtime/output_export.jl +++ b/src/time/runtime/output_export.jl @@ -1,349 +1,55 @@ """ - 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 `ModelSpec` selectors/bindings 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,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, ) - proc = isnothing(process) ? nothing : Symbol(process) - return OutputRequest(scale, var, name, proc, policy, clock) + _validate_selector_context(selector, :output_request) + app = isnothing(application) ? nothing : Symbol(application) + return OutputRequest(selector, var, name, 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..402276fb3 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,19 +48,20 @@ 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() -DataFormat(::Type{<:Nothing}) = SingletonAlike() # For meteo == Nothing +DataFormat(::Type{<:Nothing}) = SingletonAlike() # For environment == 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..fdfbd0464 --- /dev/null +++ b/src/visualization/model_graph_editor_api.jl @@ -0,0 +1,958 @@ +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, + _replace_model_spec(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, + _replace_model_spec( + spec; + model=edit.model, + name=edit.name, + on=edit.selector, + every=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, + _replace_model_spec(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] = _replace_model_spec( + spec; + inputs=inputs, + calls=calls, + on=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, + _replace_model_spec(spec; on=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(_input_schema(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, + _replace_model_spec(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, + _replace_model_spec(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, + _replace_model_spec(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, + _replace_model_spec(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, + _replace_model_spec(spec; every=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, + _replace_model_spec(spec; environment=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, + _replace_model_spec(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, + _replace_model_spec(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...)) + delete!( + get!( + model.input_default_status_variables, + object_id, + Set{Symbol}(), + ), + variable, + ) + 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...)) + delete!( + get!( + model.input_default_status_variables, + edit.object_id, + Set{Symbol}(), + ), + edit.variable, + ) + 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] = _replace_model_spec(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] = _replace_model_spec( + original; + model=edit.model, + on=edit.selector, + every=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..e2e2c290a --- /dev/null +++ b/src/visualization/model_graph_view.jl @@ -0,0 +1,1399 @@ +""" + 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) + status_views = _compile_model_status_views( + model, + applications, + applications_by_id, + input_by_target, + application_order, + ) + call_owners = _model_call_owners(call_bindings) + application_children = _compile_model_application_children( + applications, + input_bindings, + call_owners, + ) + 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), + _index_dynamic_call_bindings(model, call_bindings), + _many_input_binding_cache(model, input_bindings), + call_owners, + application_children, + status_views, + Set(application.id for application in applications), + Set(keys(status_views)), + false, + 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 `ModelSpec(...; on=...)` 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 => setdiff( + Set{Symbol}( + object.status isa Status ? Symbol.(propertynames(object.status)) : Symbol[], + ), + get(model.input_default_status_variables, object.id, Set{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_input_defaults!(model, applications) + _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 `ModelSpec(...; 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 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, declaration) + name = Symbol(variable) + is_input = role == :input + default = is_input && declaration isa Required ? nothing : + is_input ? declaration.value : + declaration + expected_type = is_input ? + _input_expected_type(declaration) : + typeof(declaration) + port = 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" => isnothing(default) ? nothing : repr(default), + "expectedType" => string(expected_type), + ) + is_input && (port["declaration"] = + declaration isa Required ? "required" : "defaulted") + return port +end + +function _model_graph_application_dict(composite_model, application) + process_model = _application_default_model(application) + spec = application.spec + inputs = _input_schema(application.spec) + outputs = outputs_(application.spec) + environment_inputs = environment_inputs_(application.spec) + environment_outputs = environment_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), + "environmentBindings" => _model_graph_json_value(environment_bindings(spec)), + "environmentWindow" => _model_graph_json_value(environment_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_routes(config, backend) + payload = _environment_config_payload(config) + provider = if payload isa NamedTuple && haskey(payload, :provider) + Symbol(payload.provider) + elseif payload isa Symbol + payload + elseif isnothing(backend) + :none + else + :model + end + sink = payload isa NamedTuple && haskey(payload, :sink) ? + Symbol(payload.sink) : + provider + return provider, sink +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(environment_inputs_(application.spec))) + produced_outputs = Symbol.(keys(environment_outputs_(application.spec))) + isempty(required_inputs) && isempty(produced_outputs) && continue + source_inputs = _environment_source_variable_names(application.spec) + config = environment_config(application.spec) + backend = _environment_backend_from_config(report.model, config) + for object_id in application.target_ids + push!(environment_bindings, ( + application_id=application.id, + object_id=object_id, + backend=backend, + required_inputs=required_inputs, + source_inputs=source_inputs, + produced_outputs=produced_outputs, + config=config, + )) + end + end + end + edges = Dict{String,Dict{String,Any}}() + for binding in environment_bindings + provider, sink = _model_graph_environment_routes( + binding.config, + binding.backend, + ) + provider_id = string("environment:", provider) + sink_id = string("environment:", sink) + 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:", 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(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, ":", + sink, object_suffix, + ) + edge = get!(edges, edge_id) do + Dict{String,Any}( + "id" => edge_id, + "source" => target_id, + "target" => sink_id, + "sourcePort" => _model_graph_port_id( + binding.application_id, + :environment_output, + variable, + ), + "targetPort" => string(sink_id, ":input:", variable), + "sourceVariable" => string(variable), + "targetVariable" => string(variable), + "sourceApplicationId" => string(binding.application_id), + "sourceObjectIds" => Any[], + "targetObjectIds" => Any[], + "provider" => string(sink), + "sink" => string(sink), + "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 = _input_schema(application.spec) + model_outputs = outputs_(application.spec) + model_environment_inputs = environment_inputs_(application.spec) + model_environment_outputs = environment_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_, declaration) 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}()) + binding_resolved = + !isnothing(binding) && + !(binding.carrier_hint == :optional_default && + isnothing(binding.carrier)) + disposition = if !isnothing(binding) && + binding.policy isa PreviousTimeStep && + !status_supplied && + declaration isa Required + :required + elseif binding_resolved + :producer_bound + elseif status_supplied + :supplied + elseif declaration isa Default + :defaulted + else + :required + end + value = if status_supplied + object.status[variable] + elseif declaration isa Default + declaration.value + else + nothing + end + row = _model_graph_initialization_row( + application.id, + object_id, + variable, + :input, + disposition, + value; + binding=binding_resolved ? binding : nothing, + declaration=declaration isa Required ? :required : :defaulted, + expected_type=_input_expected_type(declaration), + ) + 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, + declaration=nothing, + expected_type=typeof(value), +) + row = 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" => isnothing(value) ? nothing : repr(value), + "expectedType" => string(expected_type), + "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 : + disposition == :defaulted ? :model_default : + :missing, + ) : + string(binding.origin), + "previousTimeStep" => !isnothing(binding) && binding.policy isa PreviousTimeStep, + ) + isnothing(declaration) || (row["declaration"] = string(declaration)) + return row +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"] in ("required", "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..28fa370e1 --- /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=Required(Float64),) +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=Required(Float64),) +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..986c8b2a8 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,87 +1,137 @@ using PlantSimEngine +using PlantSimEngine.Diagnostics +using PlantSimEngine.GraphEditor +using PlantSimEngine.EnvironmentAPI +using PlantSimEngine.Evaluation # Include the example dummy processes: using PlantSimEngine.Examples 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 environment sampling" begin + include("test-model-environment-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 "PreviousTimeStep application-local status views" begin + include("test-model-previous-timestep-views.jl") end - @testset "Fitting" begin - include("test-fitting.jl") + @testset "Composite model binding inference" begin + include("test-model-binding-inference.jl") end - @testset "Toy models" begin - include("test-toy_models.jl") + @testset "Composite model multirate integration" begin + include("test-model-multirate-integration.jl") + end + + @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 "Model contract" begin + include("test-model-contract.jl") + end + + @testset "ModelSpec Updates" begin + include("test-updates.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 "Environment traits" begin + include("test-environment-traits.jl") end - @testset "Multiscale corner-cases" begin - include("test-corner-cases.jl") + @testset "Environment backends" begin + include("test-environment-backends.jl") end - @testset "Multithreading" begin - include("test-performance.jl") + @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 +143,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..ee16e4c2e 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)) @@ -68,4 +68,9 @@ @test df.Ra_SW_f == [5.0, 5.0] @test df.sky_fraction == [0.8, 0.8] @test names(df) == [string.(keys(vars))...] + + # Runtime meteorology lookup uses direct row indexing whenever the Tables + # row source supports it; advancing to day `i` must not rescan days 1:i. + @test PlantSimEngine._environment_row_at_step(ts, 2).Ra_SW_f == 5.0 + @test PlantSimEngine._environment_row_at_step(df, 2).sky_fraction == 0.8 end 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..cde75ee09 --- /dev/null +++ b/test/test-environment-backends.jl @@ -0,0 +1,41 @@ +using Dates + +@testset "Environment backend contract" begin + context = EnvironmentContext( + :leaf_energy, + ObjectId(:leaf_1), + :Leaf, + :energy_balance, + ) + handle = nothing + backend = GlobalConstant( + Atmosphere( + T=20.0, + Rh=0.65, + Wind=1.0, + CO2=410.0, + duration=Dates.Hour(1), + ), + ) + + @test sample(backend, handle, :T, 1.0) == 20.0 + @test sample(backend, handle, (T=23.0,), :T, 1.0) == 23.0 + @test sample_environment(backend, handle, 1.0, (:T,)).T == 20.0 + @test sample_environment(backend, handle, (T=23.0,), 1.0, (:T,)).T == 23.0 + @test PlantSimEngine.EnvironmentAPI.get_nsteps(backend) == 1 + @test base_step_seconds(backend) == 3600.0 + @test environment_variables(GlobalConstant(nothing)) == Set{Symbol}() + + @test_throws "does not provide variable `missing`" sample( + backend, + handle, + :missing, + 1.0, + ) + @test_throws "GlobalConstant is immutable" commit_environment!( + backend, + handle, + (T=21.0,), + 1.0, + ) +end diff --git a/test/test-environment-traits.jl b/test/test-environment-traits.jl new file mode 100644 index 000000000..679a607af --- /dev/null +++ b/test/test-environment-traits.jl @@ -0,0 +1,50 @@ +using Dates + +PlantSimEngine.@process "environment_trait_consumer" verbose = false + +struct EnvironmentTraitConsumerModel <: AbstractEnvironment_Trait_ConsumerModel end + +PlantSimEngine.inputs_(::EnvironmentTraitConsumerModel) = NamedTuple() +PlantSimEngine.outputs_(::EnvironmentTraitConsumerModel) = (environment_seen=0.0,) +PlantSimEngine.environment_inputs_(::EnvironmentTraitConsumerModel) = (T=0.0, CO2=400.0) + +function PlantSimEngine.run!(::EnvironmentTraitConsumerModel, status, environment, constants=nothing, context=nothing) + status.environment_seen = environment.T + return nothing +end + +@testset "Environment traits" begin + specs = Dict(:Leaf => Dict(:environment_trait_consumer => ModelSpec(EnvironmentTraitConsumerModel()))) + + @test_throws "CO2" PlantSimEngine.validate_environment_inputs( + specs, + Atmosphere(T=20.0, Rh=0.65, Wind=1.0, duration=Dates.Hour(1)) + ) + + @test PlantSimEngine.validate_environment_inputs( + specs, + (T=20.0, CO2=410.0, duration=Dates.Hour(1)) + ) === nothing + + bound_spec = ModelSpec(EnvironmentTraitConsumerModel(); + environment_bindings=(CO2=(source=:Ca, reducer=MeanReducer()),),) + bound_specs = Dict(:Leaf => Dict(:environment_trait_consumer => bound_spec)) + + @test_throws "Ca" PlantSimEngine.validate_environment_inputs( + bound_specs, + (T=20.0, CO2=410.0, duration=Dates.Hour(1)) + ) + @test PlantSimEngine.validate_environment_inputs( + bound_specs, + (T=20.0, Ca=410.0, duration=Dates.Hour(1)) + ) === nothing + + model = CompositeModel( + Object(:leaf; scale=:Leaf, status=Status(environment_seen=0.0)); + applications=( + ModelSpec(EnvironmentTraitConsumerModel(); on=One(scale=:Leaf)), + ), + environment=(T=20.0, CO2=410.0, duration=Dates.Hour(1)), + ) + @test validate_environment_inputs(model) === nothing +end diff --git a/test/test-fitting.jl b/test/test-fitting.jl index 54a0337a4..624e2baa9 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); on=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..81deb93ac --- /dev/null +++ b/test/test-maespa-model-example.jl @@ -0,0 +1,278 @@ +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 + + 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 + + environment_rows = explain_environment_bindings(result.environment) + scene_environment = only(row for row in environment_rows if row.application_id == :scene_eb) + @test scene_environment.handle.provider == :forcing + @test scene_environment.handle.sink == :canopy + @test scene_environment.produced_outputs == [:T, :Rh] + leaf_environment = only( + row for row in environment_rows + if row.application_id == :plant_A__energy_balance && row.object_id == :plant_A_leaf_1 + ) + @test leaf_environment.handle.provider == :canopy + @test isnothing(leaf_environment.handle.sink) + @test :T in leaf_environment.required_inputs + @test :Rh in leaf_environment.required_inputs + + 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 + @test !hasproperty(scene_status, :T) + @test !hasproperty(scene_status, :Rh) + 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 + @test model.environment isa MaespaSingleLayerEnvironment + @test model.environment.canopy.T ≈ scene_status.canopy_tair + @test model.environment.canopy.Rh ≈ scene_status.canopy_rh + @test !(model.environment.canopy === model.environment.forcing) + + source = read(joinpath(@__DIR__, "..", "examples", "maespa_model_example.jl"), String) + @test !occursin("support.process", source) + @test !occursin("providers_by_application", source) + @test !occursin("MaespaEnvironmentWrite", source) + @test !occursin("cells_by_status", source) + @test !occursin("geometry(object)", source) + @test !occursin("PlantSimEngine.environment_outputs_(::CanopyAir)", source) + @test !occursin("with_environment!", source) + @test !occursin("update_environment!", source) + @test !occursin("EnvironmentSupport", source) + @test occursin( + "PlantSimEngine.EnvironmentAPI.bind_environment", + source, + ) + @test occursin( + "PlantSimEngine.EnvironmentAPI.commit_environment!", + source, + ) + @test !occursin("function PlantSimEngine.bind_environment", source) + @test !occursin("function PlantSimEngine.sample", source) + + 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 + environment = maespa_meteo(; nhours=1) + + scene_status = _maespa_model_status() + @test_throws "SceneEB did not converge after 0 iterations" _solve_model_energy_balance!( + SceneEB(0, 0.03, 0.005), + nothing, + scene_status, + first(environment), + ) +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, lai_status, nothing, 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 = canopy_air_update(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 = canopy_air_update(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-model-api-stabilization.jl b/test/test-model-api-stabilization.jl new file mode 100644 index 000000000..72775747c --- /dev/null +++ b/test/test-model-api-stabilization.jl @@ -0,0 +1,1167 @@ +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, + status, + environment, + constants, + context, +) + status.signal += 1 + return nothing +end + +PlantSimEngine.@process "stabilization_consumer" verbose = false + +struct StabilizationConsumerModel <: AbstractStabilization_ConsumerModel end + +PlantSimEngine.inputs_(::StabilizationConsumerModel) = (signal=Required(Float64), supplied=Required(Float64)) +PlantSimEngine.outputs_(::StabilizationConsumerModel) = (observed=0.0,) + +function PlantSimEngine.run!( + ::StabilizationConsumerModel, + status, + environment, + constants, + context, +) + 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, + status, + environment, + constants, + context, +) + status.seen_revision = Advanced.model_revision(runtime_model(context)) + 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.environment_inputs_(::StabilizationEnvironmentModel) = (T=0.0,) + +function PlantSimEngine.run!( + ::StabilizationEnvironmentModel, + status, + environment, + constants, + context, +) + status.temperature_seen = environment.T + return nothing +end + +PlantSimEngine.@process "stabilization_lagged_sum" verbose = false + +struct StabilizationLaggedSumModel <: AbstractStabilization_Lagged_SumModel end + +PlantSimEngine.inputs_(::StabilizationLaggedSumModel) = + (previous_signals=Default([0.0]),) +PlantSimEngine.outputs_(::StabilizationLaggedSumModel) = (lagged_total=0.0,) + +function PlantSimEngine.run!( + ::StabilizationLaggedSumModel, + status, + environment, + constants, + context, +) + status.lagged_total = sum(status.previous_signals) + 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(); on=One(name=:scene)), + ModelSpec(StabilizationConsumerModel(); on=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 == :required, + 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 = Set(names(PlantSimEngine)) + expected_public_names = Set([ + Symbol("@process"), + :AbstractModel, + :Advanced, + :Aggregate, + :Ancestor, + :Atmosphere, + :Call, + :CallTarget, + :CallTargets, + :ClockSpec, + :CompositeModel, + :CompositeModelTemplate, + :Constants, + :Default, + :Diagnostics, + :Environment, + :EnvironmentAPI, + :Evaluation, + :GraphEditor, + :HoldLast, + :Input, + :Integrate, + :Interpolate, + :Many, + :ModelSpec, + :Object, + :ObjectId, + :ObjectInstance, + :One, + :OptionalOne, + :OutputRequest, + :Override, + :PlantSimEngine, + :PreviousTimeStep, + :Relation, + :Required, + :RunContext, + :SceneScope, + :SchedulePolicy, + :Scope, + :Self, + :SelfPlant, + :Simulation, + :Status, + :Subtree, + :Updates, + :Weather, + :add_organ!, + :application_name, + :applies_to, + :bounds, + :call_targets, + :collect_outputs, + :commit_environment!, + :continue!, + :current_step, + :dep, + :environment_bindings, + :environment_config, + :environment_hint, + :environment_inputs, + :environment_outputs, + :environment_window, + :final_state, + :geometry, + :init_variables, + :inputs, + :mark_environment_binding_dirty!, + :model_calls, + :model_objects, + :move_object!, + :object_ids, + :objects_from_mtg, + :output_policy, + :output_routing, + :outputs, + :position, + :process, + :register_object!, + :remove_object!, + :reparent_object!, + :resolve_object_ids, + :resolve_objects, + :run!, + :run_call!, + :runtime_model, + :step!, + :timespec, + :timestep_hint, + :update_geometry!, + :updates, + :validate_environment_inputs, + :value_inputs, + :variables, + ]) + @test public_names == expected_public_names + advanced_names = names(PlantSimEngine.Advanced) + diagnostic_names = names(PlantSimEngine.Diagnostics) + graph_editor_names = names(PlantSimEngine.GraphEditor) + environment_api_names = names(PlantSimEngine.EnvironmentAPI) + evaluation_names = names(PlantSimEngine.Evaluation) + @test Set(advanced_names) == Set([ + :Advanced, + :CompiledCompositeModel, + :CompiledEnvironmentBinding, + :CompiledEnvironmentBindings, + :CompiledModelApplication, + :CompiledModelCallBinding, + :CompiledModelInputBinding, + :ObjectRefVector, + :ObjectRegistry, + :RuntimePerformanceCounters, + :TimeStepTable, + :bindings_dirty, + :compile_composite_model, + :compile_environment_bindings, + :compiled_bindings, + :compiled_environment_bindings, + :environment_bindings_dirty, + :environment_revision, + :model_revision, + :refresh_bindings!, + :refresh_environment_bindings!, + :runtime_performance, + ]) + @test Set(diagnostic_names) == Set([ + :Diagnostics, + :ObjectAddress, + :explain_applications, + :explain_bindings, + :explain_calls, + :explain_environment, + :explain_environment_bindings, + :explain_execution_plan, + :explain_initialization, + :explain_instances, + :explain_objects, + :explain_output_retention, + :explain_outputs, + :explain_schedule, + :explain_scopes, + :explain_writers, + :has_reference_carrier, + :input_carrier, + :input_value, + :object_address, + ]) + @test Set(graph_editor_names) == Set([ + :AbstractModelGraphEdit, + :AbstractModelGraphEditorSession, + :AddModelApplication, + :AddModelObject, + :BreakModelCycle, + :CompositeModelCompilationReport, + :GraphEditor, + :MarkModelPreviousTimeStep, + :ModelGraphDiagnostic, + :ModelGraphView, + :RemoveModelApplication, + :RemoveModelCallBinding, + :RemoveModelInputBinding, + :RemoveModelInstanceOverride, + :RemoveModelObject, + :RemoveModelObjectOverride, + :RemoveModelObjectStatus, + :RemoveModelTemplateApplication, + :RenameModelApplication, + :ReparentModelObject, + :ReplaceModelApplicationModel, + :SetModelApplicationEnvironment, + :SetModelApplicationTargets, + :SetModelApplicationTimeStep, + :SetModelCallBinding, + :SetModelInputBinding, + :SetModelInstanceOverride, + :SetModelObjectMetadata, + :SetModelObjectOverride, + :SetModelObjectStatus, + :SetModelObjectStatuses, + :SetModelOutputRouting, + :SetModelUpdateOrdering, + :UnmarkModelPreviousTimeStep, + :UpdateModelApplication, + :UpdateModelTemplateApplication, + :apply_edit!, + :apply_model_graph_edit, + :available_models, + :available_processes, + :compile_model_graph, + :compile_model_report, + :current_model, + :edit_graph, + :model_constructor_descriptor, + :model_descriptor, + :model_graph_view, + :model_graph_view_html, + :model_graph_view_json, + :redo!, + :undo!, + :write_model_graph_view, + ]) + @test Set(environment_api_names) == Set([ + :AbstractEnvironmentBackend, + :EnvironmentAPI, + :EnvironmentContext, + :GlobalConstant, + :base_step_seconds, + :bind_environment, + :commit_environment!, + :environment_backend, + :environment_variables, + :get_nsteps, + :sample, + :sample_environment, + :update_index!, + ]) + @test Set(evaluation_names) == Set([ + :EF, + :Evaluation, + :NRMSE, + :RMSE, + :dr, + :fit, + ]) + for public_name in ( + :CompositeModel, + :CompositeModelTemplate, + :Simulation, + :RunContext, + :CallTarget, + :commit_environment!, + :final_state, + :model_objects, + :runtime_model, + :Diagnostics, + :GraphEditor, + :EnvironmentAPI, + :Evaluation, + ) + @test public_name in public_names + end + for diagnostic_name in ( + :explain_objects, + :explain_applications, + :explain_bindings, + :explain_calls, + :explain_schedule, + :input_carrier, + :object_address, + ) + @test diagnostic_name ∉ public_names + @test diagnostic_name ∈ diagnostic_names + end + for graph_editor_name in ( + :ModelGraphView, + :compile_model_report, + :model_graph_view, + :AddModelApplication, + :edit_graph, + ) + @test graph_editor_name ∉ public_names + @test graph_editor_name ∈ graph_editor_names + end + for environment_api_name in ( + :AbstractEnvironmentBackend, + :EnvironmentContext, + :environment_backend, + :sample_environment, + ) + @test environment_api_name ∉ public_names + @test environment_api_name ∈ environment_api_names + end + for evaluation_name in (:fit, :RMSE, :NRMSE, :EF, :dr) + @test evaluation_name ∉ public_names + @test evaluation_name ∈ evaluation_names + end + for reducer_name in ( + :AbstractTimeReducer, + :MeanWeighted, + :MeanReducer, + :SumReducer, + :MinReducer, + :MaxReducer, + :FirstReducer, + :LastReducer, + :RadiationEnergy, + ) + @test reducer_name ∉ public_names + end + for extension_name in ( + :inputs_, + :outputs_, + :environment_inputs_, + :environment_outputs_, + ) + @test extension_name ∉ public_names + @test isdefined(PlantSimEngine, extension_name) + end + for internal_name in ( + :ObjectRegistry, + :CompiledCompositeModel, + :CompiledModelApplication, + :compile_composite_model, + :refresh_bindings!, + :bindings_dirty, + :RuntimePerformanceCounters, + :runtime_performance, + ) + @test internal_name ∉ public_names + @test internal_name ∈ advanced_names + end + for removed_name in ( + :EnvironmentSupport, + :AppliesTo, + :Inputs, + :Calls, + :TimeStep, + :OutputRouting, + :Kind, + :Species, + :Scale, + :with_environment!, + :update_environment!, + :scatter!, + :scatter_environment_outputs!, + ) + @test removed_name ∉ public_names + @test !isdefined(PlantSimEngine, removed_name) + end + + @test_throws MethodError ModelSpec( + StabilizationSourceModel(); + applies_to=One(scale=:Leaf), + ) + @test_throws MethodError ModelSpec( + StabilizationSourceModel(); + timestep=Dates.Hour(1), + ) + @test_throws MethodError run!( + CompositeModel(StabilizationSourceModel()); + tracked_outputs=nothing, + ) + @test_throws UndefKeywordError Override( + object=:leaf, + process=:stabilization_source, + model=StabilizationSourceModel(), + ) + @test_throws "environment=Environment" ModelSpec( + StabilizationEnvironmentModel(); + environment=(provider=:global,), + ) +end + +@testset "composite model template constructor" begin + template = CompositeModelTemplate(( + ModelSpec(StabilizationSourceModel(); on=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 + @test final_state(default_simulation) == (signal=2.0,) + @test final_state(default_simulation, :scene) == (signal=2.0,) + @test final_state( + default_simulation, + OptionalOne(scale=:Leaf), + ) === nothing + @test final_state(default_simulation, Many()) == + Dict(:scene => (signal=2.0,)) + + multi_object_scene = CompositeModel( + Object(:leaf_1; scale=:Leaf), + Object(:leaf_2; scale=:Leaf); + applications=( + ModelSpec( + StabilizationSourceModel(); + on=Many(scale=:Leaf), + ), + ), + ) + multi_object_simulation = run!(multi_object_scene) + @test_throws Exception final_state(multi_object_simulation) + @test final_state(multi_object_simulation, Many(scale=:Leaf)) == + Dict( + :leaf_1 => (signal=1.0,), + :leaf_2 => (signal=1.0,), + ) + + compact_display = sprint(show, default_simulation) + @test compact_display == + "Simulation(steps=2, objects=1, applications=1, retained_streams=0)" + detailed_display = sprint(show, MIME"text/plain"(), default_simulation) + @test occursin("elapsed steps: 2", detailed_display) + @test occursin("objects: 1", detailed_display) + @test occursin("applications: 1", detailed_display) + @test occursin("retained streams: 0", detailed_display) + @test occursin("outputs=:all", detailed_display) + @test occursin("OutputRequest", detailed_display) + + retained_scene = CompositeModel(StabilizationSourceModel()) + simulation = run!(retained_scene; steps=2, outputs=:all) + @test current_step(simulation) == 2 + retained_display = sprint(show, MIME"text/plain"(), simulation) + @test occursin("retained streams: 1", retained_display) + @test !occursin("hint:", retained_display) + @test last.(outputs(simulation)[ + (:stabilization_source, ObjectId(:scene), :signal) + ]) == [1.0, 2.0] + + state_before_continuation = final_state(simulation) + @test continue!(simulation; steps=2) === simulation + @test state_before_continuation == (signal=2.0,) + @test final_state(simulation) == (signal=4.0,) + @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(); on=One(scale=:Leaf)), + ModelSpec(StabilizationSourceModel(); on=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, on=One(scale=:Leaf)), + ModelSpec(StabilizationSourceModel(); name=:source_b, on=One(scale=:Leaf), output_routing=(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_writers(named_scene) isa Vector + + ambiguous_process_scene = CompositeModel( + Object(:leaf; scale=:Leaf, status=Status(supplied=0.0)); + applications=( + ModelSpec(StabilizationSourceModel(); name=:source_a, on=One(scale=:Leaf)), + ModelSpec(StabilizationSourceModel(); name=:source_b, on=One(scale=:Leaf), output_routing=(signal=:stream_only,)), + ModelSpec(StabilizationConsumerModel(); name=:consumer, on=One(scale=:Leaf), inputs=(:signal => One(within=Self(), application=:source))), + ), + ) + @test_throws "application `source`, but no matching source application was found" explain_bindings( + ambiguous_process_scene, + ) + + ordered_writer_scene = CompositeModel( + Object(:leaf; scale=:Leaf, status=Status(supplied=0.0)); + applications=( + ModelSpec(StabilizationSourceModel(); name=:source_a, on=One(scale=:Leaf)), + ModelSpec(StabilizationSourceModel(); name=:source_b, on=One(scale=:Leaf), updates=Updates(:signal; after=:source_a)), + ModelSpec(StabilizationConsumerModel(); name=:consumer, on=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, on=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, on=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, on=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 + +@testset "opt-in runtime performance counters" begin + disabled_model = CompositeModel(StabilizationSourceModel()) + disabled_simulation = run!(disabled_model; steps=2) + @test isnothing(Advanced.runtime_performance(disabled_simulation)) + + model = CompositeModel( + Object(:leaf_1; scale=:Leaf); + applications=( + ModelSpec(StabilizationSourceModel(); name=:source, on=Many(scale=:Leaf)), + ), + ) + simulation = run!( + model; + steps=3, + outputs=:all, + performance=true, + ) + performance = Advanced.runtime_performance(simulation) + + @test performance.counts[:steps_executed] == 3 + @test performance.counts[:application_groups_visited] == 3 + @test performance.counts[:execution_batches_visited] == 3 + @test performance.counts[:execution_targets_visited] == 3 + @test performance.counts[:initial_status_views_constructed] == 1 + @test performance.counts[:initial_input_bindings_constructed] == 0 + @test performance.counts[:initial_call_bindings_constructed] == 0 + @test performance.counts[:initial_environment_bindings_constructed] == 1 + @test performance.counts[:initial_execution_targets_constructed] == 1 + @test performance.counts[:initial_execution_batches_constructed] == 1 + @test performance.elapsed_seconds[:step_execution] >= 0.0 + @test performance.elapsed_seconds[:initial_composite_compile] >= 0.0 + @test performance.elapsed_seconds[:initial_binding_compile] >= 0.0 + @test performance.elapsed_seconds[:application_target_compile] >= 0.0 + @test performance.elapsed_seconds[:call_binding_compile] >= 0.0 + @test performance.elapsed_seconds[:input_binding_compile] >= 0.0 + @test performance.elapsed_seconds[:application_order_compile] >= 0.0 + @test performance.elapsed_seconds[:status_view_compile] >= 0.0 + @test performance.elapsed_seconds[:initial_execution_plan_compile] >= 0.0 + @test performance.elapsed_seconds[ + :initial_execution_plan_and_model_bundle_compile + ] >= 0.0 + @test performance.elapsed_seconds[:temporal_input_materialization] >= 0.0 + @test performance.elapsed_seconds[:environment_sampling] >= 0.0 + @test performance.elapsed_seconds[:scientific_kernel_execution] >= 0.0 + @test performance.elapsed_seconds[:output_publication] >= 0.0 + + original_view = + simulation.compiled.status_views_by_target[(:source, ObjectId(:leaf_1))] + register_object!(model, Object(:leaf_2; scale=:Leaf)) + continue!(simulation) + refreshed = Advanced.runtime_performance(simulation) + @test refreshed.counts[:steps_executed] == 4 + @test refreshed.counts[:binding_refreshes] == 1 + @test refreshed.counts[:dirty_binding_objects] == 1 + @test refreshed.counts[:status_views_constructed] == 1 + @test refreshed.counts[:input_binding_targets_replaced] == 0 + @test refreshed.counts[:call_binding_targets_replaced] == 0 + @test refreshed.counts[:environment_bindings_replaced] == 1 + @test refreshed.counts[:output_retention_reuses] == 1 + @test !haskey(refreshed.counts, :output_retention_compiles) + @test refreshed.counts[:execution_plan_compiles] == 1 + @test refreshed.counts[:execution_targets_constructed] == 1 + @test refreshed.counts[:execution_batches_constructed] == 0 + @test refreshed.counts[:execution_groups_updated_in_place] == 1 + @test refreshed.elapsed_seconds[:application_target_refresh] >= 0.0 + @test refreshed.elapsed_seconds[:call_binding_refresh] >= 0.0 + @test refreshed.elapsed_seconds[:input_binding_refresh] >= 0.0 + @test refreshed.elapsed_seconds[:application_order_refresh] >= 0.0 + @test refreshed.elapsed_seconds[:status_view_refresh] >= 0.0 + @test simulation.compiled.status_views_by_target[ + (:source, ObjectId(:leaf_1)) + ] === original_view + + collect_outputs(simulation; sink=nothing) + collected = Advanced.runtime_performance(simulation) + @test collected.counts[:output_collections] == 1 + @test collected.elapsed_seconds[:output_collection] >= 0.0 +end + +@testset "incremental lifecycle preserves temporal state" begin + model = CompositeModel( + Object(:plant; scale=:Plant), + Object(:leaf_1; scale=:Leaf, parent=:plant); + applications=( + ModelSpec(StabilizationSourceModel(); name=:source, on=Many(scale=:Leaf)), + ModelSpec(StabilizationLaggedSumModel(); name=:lagged_sum, on=One(scale=:Plant), inputs=(PreviousTimeStep(:previous_signals) => Many( + scale=:Leaf, + within=Subtree(), + application=:source, + var=:signal, + ),)), + ), + ) + simulation = run!(model; performance=true) + plant_status = only(model_objects(model; scale=:Plant)).status + @test plant_status.lagged_total == 0.0 + + original_leaf_view = + simulation.compiled.status_views_by_target[(:source, ObjectId(:leaf_1))] + original_plant_view = + simulation.compiled.status_views_by_target[(:lagged_sum, ObjectId(:plant))] + original_temporal_input = only(original_plant_view.temporal_inputs) + @test collect(original_temporal_input.reference[]) == [0.0] + + register_object!( + model, + Object(:leaf_2; scale=:Leaf, parent=:plant), + ) + continue!(simulation) + + refreshed_leaf_view = + simulation.compiled.status_views_by_target[(:source, ObjectId(:leaf_1))] + refreshed_plant_view = + simulation.compiled.status_views_by_target[(:lagged_sum, ObjectId(:plant))] + refreshed_temporal_input = only(refreshed_plant_view.temporal_inputs) + @test refreshed_leaf_view === original_leaf_view + @test refreshed_plant_view !== original_plant_view + @test refreshed_temporal_input.binding.source_ids == + ObjectId.([:leaf_1, :leaf_2]) + @test plant_status.lagged_total == 1.0 + + continue!(simulation) + @test plant_status.lagged_total == 3.0 + performance = Advanced.runtime_performance(simulation) + @test performance.counts[:status_views_constructed] == 2 + @test performance.counts[:output_retention_reuses] == 1 + @test !haskey(performance.counts, :output_retention_compiles) +end + +@testset "incremental execution plan reuses unaffected groups" begin + model = CompositeModel( + Object(:scene; scale=:Scene), + Object(:leaf_1; scale=:Leaf, parent=:scene); + applications=( + ModelSpec(StabilizationSourceModel(); name=:leaf_source, on=Many(scale=:Leaf)), + ModelSpec(StabilizationSourceModel(); name=:scene_source, on=One(scale=:Scene)), + ), + ) + simulation = run!(model; performance=true) + original_scene_group = only( + group for group in simulation.execution_plan.groups + if group.application.id == :scene_source + ) + original_scene_target = only(only(original_scene_group.batches).targets) + + register_object!( + model, + Object(:leaf_2; scale=:Leaf, parent=:scene), + ) + continue!(simulation) + + refreshed_scene_group = only( + group for group in simulation.execution_plan.groups + if group.application.id == :scene_source + ) + refreshed_scene_target = only(only(refreshed_scene_group.batches).targets) + performance = Advanced.runtime_performance(simulation) + @test refreshed_scene_group === original_scene_group + @test refreshed_scene_target === original_scene_target + @test performance.counts[:execution_groups_reused] == 1 + @test performance.counts[:execution_targets_constructed] == 1 + @test performance.counts[:execution_batches_constructed] == 0 + @test performance.counts[:execution_groups_updated_in_place] == 1 +end + +function stabilization_lifecycle_scene(nleaves_per_plant) + objects = Object[ + Object(:scene; scale=:Scene), + Object(:plant_a; scale=:Plant, parent=:scene), + Object(:plant_b; scale=:Plant, parent=:scene), + Object(:moving_leaf; scale=:Leaf, parent=:plant_a), + Object(:removed_leaf; scale=:Leaf, parent=:plant_a), + Object( + :geometry_leaf; + scale=:Leaf, + parent=:plant_b, + geometry=(cell=:sun,), + ), + ] + for plant in (:plant_a, :plant_b) + for index in 1:nleaves_per_plant + push!( + objects, + Object( + Symbol(plant, :_leaf_, index); + scale=:Leaf, + parent=plant, + ), + ) + end + end + return CompositeModel( + objects...; + applications=( + ModelSpec( + StabilizationSourceModel(); + name=:source, + on=Many(scale=:Leaf), + ), + ModelSpec( + StabilizationLaggedSumModel(); + name=:plant_sum, + on=Many(scale=:Plant), + inputs=( + :previous_signals => Many( + scale=:Leaf, + within=Subtree(), + application=:source, + var=:signal, + ), + ), + ), + ), + environment=(T=20.0, duration=Hour(1)), + ) +end + +function stabilization_lifecycle_work(nleaves_per_plant, operation) + model = stabilization_lifecycle_scene(nleaves_per_plant) + simulation = run!( + model; + steps=2, + outputs=:all, + performance=true, + ) + unaffected_key = ( + :source, + ObjectId(Symbol(:plant_b_leaf_, nleaves_per_plant)), + ) + unaffected_view = + simulation.compiled.status_views_by_target[unaffected_key] + + if operation == :add + register_object!( + model, + Object(:added_leaf; scale=:Leaf, parent=:plant_a), + ) + elseif operation == :remove + remove_object!(model, :removed_leaf) + elseif operation == :reparent + reparent_object!(model, :moving_leaf, :plant_b) + elseif operation == :move + move_object!(model, :geometry_leaf, (cell=:shade,)) + else + error("Unknown stabilization lifecycle operation `$(operation)`.") + end + continue!(simulation) + + @test simulation.compiled.status_views_by_target[unaffected_key] === + unaffected_view + performance = Advanced.runtime_performance(simulation) + return ( + status_views=get(performance.counts, :status_views_constructed, 0), + execution_targets=get( + performance.counts, + :execution_targets_constructed, + 0, + ), + execution_batches=get( + performance.counts, + :execution_batches_constructed, + 0, + ), + execution_groups_updated=get( + performance.counts, + :execution_groups_updated_in_place, + 0, + ), + binding_refreshes=get(performance.counts, :binding_refreshes, 0), + environment_refreshes=get( + performance.counts, + :environment_refreshes, + 0, + ), + ), simulation +end + +function stabilization_lifecycle_refresh_allocations( + nleaves_per_plant, + operation, +) + model = stabilization_lifecycle_scene(nleaves_per_plant) + simulation = run!(model; outputs=:none) + if operation == :remove + remove_object!(model, :removed_leaf) + elseif operation == :reparent + reparent_object!(model, :moving_leaf, :plant_b) + elseif operation == :move + move_object!(model, :geometry_leaf, (cell=:shade,)) + else + error("Unknown stabilization lifecycle operation `$(operation)`.") + end + return @allocated PlantSimEngine._refresh_simulation_runtime!(simulation) +end + +@testset "lifecycle work counts scale with the structural delta" begin + for operation in (:add, :remove, :reparent, :move) + small_work, small_simulation = + stabilization_lifecycle_work(8, operation) + large_work, large_simulation = + stabilization_lifecycle_work(256, operation) + @test small_work == large_work + @test small_work.status_views <= 3 + @test small_work.execution_targets <= 3 + @test small_work.execution_batches == 0 + @test small_work.execution_groups_updated <= 3 + + if operation == :remove + @test !haskey( + small_simulation.compiled.status_views_by_target, + (:source, ObjectId(:removed_leaf)), + ) + @test haskey( + outputs(small_simulation), + (:source, ObjectId(:removed_leaf), :signal), + ) + @test all( + row.object_id != :removed_leaf + for row in Diagnostics.explain_environment_bindings( + small_simulation, + ) + ) + elseif operation == :reparent + rows = Diagnostics.explain_bindings(small_simulation) + plant_a = only( + row for row in rows + if row.application_id == :plant_sum && + row.consumer_id == :plant_a + ) + plant_b = only( + row for row in rows + if row.application_id == :plant_sum && + row.consumer_id == :plant_b + ) + @test :moving_leaf ∉ plant_a.source_ids + @test :moving_leaf ∈ plant_b.source_ids + end + end + + for operation in (:remove, :reparent, :move) + stabilization_lifecycle_refresh_allocations(8, operation) + small_allocations = minimum( + stabilization_lifecycle_refresh_allocations(8, operation) + for _ in 1:2 + ) + large_allocations = minimum( + stabilization_lifecycle_refresh_allocations(2048, operation) + for _ in 1:2 + ) + @test large_allocations <= small_allocations + 16_384 + end +end diff --git a/test/test-model-binding-inference.jl b/test/test-model-binding-inference.jl new file mode 100644 index 000000000..5ccb5a45c --- /dev/null +++ b/test/test-model-binding-inference.jl @@ -0,0 +1,147 @@ +using PlantSimEngine +using Test + +PlantSimEngine.@process "lineage_source" verbose = false +PlantSimEngine.@process "lineage_consumer" verbose = false +PlantSimEngine.@process "lineage_sum" verbose = false + +struct LineageSourceModel{T} <: AbstractLineage_SourceModel + value::T +end +struct LineageConsumerModel <: AbstractLineage_ConsumerModel end +struct LineageSumModel <: AbstractLineage_SumModel end +PlantSimEngine.inputs_(::LineageSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::LineageSourceModel) = (signal=0.0,) +function PlantSimEngine.run!(model::LineageSourceModel, status, environment, constants, context) + status.signal = model.value +end +PlantSimEngine.inputs_(::LineageConsumerModel) = (signal=Required(Float64),) +PlantSimEngine.outputs_(::LineageConsumerModel) = (observed=0.0,) +function PlantSimEngine.run!(::LineageConsumerModel, status, environment, constants, context) + status.observed = status.signal +end +PlantSimEngine.inputs_(::LineageSumModel) = (signals=Required(Vector{Float64}),) +PlantSimEngine.outputs_(::LineageSumModel) = (total=0.0,) +function PlantSimEngine.run!(::LineageSumModel, status, environment, constants, context) + status.total = sum(status.signals) +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, on=One(scale=:Plant)), + ModelSpec(LineageSourceModel(2.0); name=:soil_source, on=One(scale=:Soil)), + ModelSpec(LineageSourceModel(3.0); name=:leaf_source, on=One(scale=:Leaf)), + ModelSpec(LineageConsumerModel(); name=:leaf_consumer, on=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 + + @test_throws "`process=` in scenario" ModelSpec( + LineageConsumerModel(); + name=:leaf_consumer, + on=One(scale=:Leaf), + inputs=(:signal => One( + within=SceneScope(), + process=:lineage_source, + var=:signal, + ),), + ) + + 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, on=One(scale=:Plant)), + ModelSpec(LineageSourceModel(2.0); name=:soil_source, on=One(scale=:Soil)), + ModelSpec(LineageConsumerModel(); name=:leaf_consumer, on=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 + +@testset "explicit current Status bindings" begin + scalar = CompositeModel( + Object(:leaf; scale=:Leaf, status=Status(signal=4.0)); + applications=( + ModelSpec(LineageConsumerModel(); name=:status_consumer, on=One(scale=:Leaf), inputs=(:signal => One( + within=Self(), + var=:signal, + from_status=true, + after=:later_source, + ),)), + ModelSpec(LineageSourceModel(10.0); name=:later_source, on=One(scale=:Leaf)), + ), + ) + scalar_compiled = Advanced.refresh_bindings!(scalar) + scalar_binding = only(explain_bindings(scalar_compiled)) + @test isempty(scalar_binding.source_application_ids) + @test scalar_binding.order_after_application_ids == [:later_source] + @test scalar_binding.carrier_kind == :ref + @test getproperty.(explain_schedule(scalar_compiled), :application_id) == + [:later_source, :status_consumer] + run!(scalar) + scalar_status = only(model_objects(scalar; scale=:Leaf)).status + @test scalar_status.observed == 10.0 + @test scalar_status.signal == 10.0 + + dynamic_many = CompositeModel( + Object(:plant; scale=:Plant, status=Status(total=0.0)), + Object(:leaf_1; scale=:Leaf, parent=:plant, status=Status(signal=1.0)); + applications=( + ModelSpec(LineageSumModel(); name=:status_sum, on=One(scale=:Plant), inputs=(:signals => Many( + scale=:Leaf, + within=Subtree(), + var=:signal, + from_status=true, + ),)), + ModelSpec(LineageSourceModel(5.0); name=:later_sources, on=Many(scale=:Leaf)), + ), + ) + simulation = run!(dynamic_many; outputs=:none) + @test only(model_objects(dynamic_many; scale=:Plant)).status.total == 1.0 + register_object!( + dynamic_many, + Object(:leaf_2; scale=:Leaf, status=Status(signal=2.0)); + parent=:plant, + ) + continue!(simulation) + @test only(model_objects(dynamic_many; scale=:Plant)).status.total == 7.0 + + invalid = CompositeModel( + Object(:leaf; scale=:Leaf, status=Status(signal=1.0)); + applications=( + ModelSpec(LineageConsumerModel(); name=:invalid_status_consumer, on=One(scale=:Leaf), inputs=(:signal => One( + within=Self(), + var=:signal, + application=:missing, + from_status=true, + ),)), + ), + ) + @test_throws "`from_status=true` cannot be combined with `application=`" Advanced.refresh_bindings!( + invalid, + ) +end diff --git a/test/test-model-configuration-errors.jl b/test/test-model-configuration-errors.jl new file mode 100644 index 000000000..8a9f3f7a2 --- /dev/null +++ b/test/test-model-configuration-errors.jl @@ -0,0 +1,71 @@ +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 InvalidEnvironmentHintProbeModel <: AbstractInvalid_Hint_ProbeModel end +struct InvalidTimestepHintProbeModel <: AbstractInvalid_Hint_ProbeModel end +struct ConfigurationProbeModel <: AbstractConfiguration_ProbeModel end +struct ConfigurationConsumerModel <: AbstractConfiguration_ConsumerModel end +PlantSimEngine.inputs_(::Union{InvalidEnvironmentHintProbeModel,InvalidTimestepHintProbeModel}) = NamedTuple() +PlantSimEngine.outputs_(::Union{InvalidEnvironmentHintProbeModel,InvalidTimestepHintProbeModel}) = (value=0.0,) +PlantSimEngine.environment_hint(::Type{<:InvalidEnvironmentHintProbeModel}) = 42 +PlantSimEngine.timestep_hint(::Type{<:InvalidTimestepHintProbeModel}) = "hourly" +PlantSimEngine.inputs_(::ConfigurationProbeModel) = NamedTuple() +PlantSimEngine.outputs_(::ConfigurationProbeModel) = (value=0.0,) +PlantSimEngine.environment_inputs_(::ConfigurationProbeModel) = (T=0.0,) +PlantSimEngine.inputs_(::ConfigurationConsumerModel) = (value=Required(Float64),) +PlantSimEngine.outputs_(::ConfigurationConsumerModel) = (observed=0.0,) + +function invalid_hint_scene(model) + return CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec(model; name=:invalid_hint, on=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, on=One(scale=:Leaf), every=Month(1)), + ), + environment=(duration=Hour(1),), + ) + @test_throws "Unsupported non-fixed period" Advanced.refresh_bindings!(monthly_scene) + @test_throws "Invalid environment_hint" Advanced.refresh_bindings!( + invalid_hint_scene(InvalidEnvironmentHintProbeModel()) + ) + @test_throws "Invalid timestep_hint" Advanced.refresh_bindings!( + invalid_hint_scene(InvalidTimestepHintProbeModel()) + ) + invalid_environment = CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec(ConfigurationProbeModel(); name=:probe, on=One(scale=:Leaf), environment=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, on=One(name=:source)), + ModelSpec(ConfigurationConsumerModel(); name=:consumer, on=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..e5bf691f6 --- /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.environment_hint(::Type{<:ContractExplicitModel}) = (window=Dates.Hour(2),) +PlantSimEngine.environment_inputs_(::ContractExplicitModel) = (T=0,) +PlantSimEngine.environment_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 environment_hint(model) === nothing + @test PlantSimEngine.environment_inputs_(model) == NamedTuple() + @test PlantSimEngine.environment_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 environment_hint(explicit) == (window=Dates.Hour(2),) + @test PlantSimEngine.environment_inputs_(explicit) == (T=0,) + @test PlantSimEngine.environment_outputs_(explicit) == (T=0,) +end diff --git a/test/test-model-environment-sampling.jl b/test/test-model-environment-sampling.jl new file mode 100644 index 000000000..fb7bbac38 --- /dev/null +++ b/test/test-model-environment-sampling.jl @@ -0,0 +1,110 @@ +using Dates +using PlantMeteo +using PlantSimEngine +using Test + +PlantSimEngine.@process "environment_sampling_probe" verbose = false +struct EnvironmentSamplingProbeModel <: AbstractEnvironment_Sampling_ProbeModel end +PlantSimEngine.inputs_(::EnvironmentSamplingProbeModel) = NamedTuple() +PlantSimEngine.outputs_(::EnvironmentSamplingProbeModel) = ( + 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.environment_inputs_(::EnvironmentSamplingProbeModel) = ( + T=0.0, + Tmin=0.0, + Tmax=0.0, + Rh=0.0, + Ri_SW_f=0.0, + Ri_SW_q=0.0, +) +PlantSimEngine.environment_hint(::Type{<:EnvironmentSamplingProbeModel}) = ( + 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!(::EnvironmentSamplingProbeModel, status, environment, constants, context) + status.mean_T = environment.T + status.min_T = environment.Tmin + status.max_T = environment.Tmax + status.mean_Rh = environment.Rh + status.mean_radiation = environment.Ri_SW_f + status.radiation_energy = environment.Ri_SW_q +end + +@testset "environment aggregation and model hint" begin + if PlantSimEngine._has_environment_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(EnvironmentSamplingProbeModel(); name=:probe, on=One(scale=:Leaf), every=Hour(2), environment=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 environment_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(EnvironmentSamplingProbeModel(); name=:probe, on=Many(scale=:Leaf), every=Hour(2), environment=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=EnvironmentSamplingProbeModel(), + ), + ), + ) + override_scene = CompositeModel(instance; environment=weather) + override_compiled = Advanced.refresh_bindings!(override_scene) + override_spec = override_compiled.applications_by_id[:plant__probe].spec + @test environment_window(override_spec) isa PlantMeteo.RollingWindow + @test environment_window(override_spec).dt == 2.0 + @test environment_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-graph-editor-extension.jl b/test/test-model-graph-editor-extension.jl new file mode 100644 index 000000000..77432d653 --- /dev/null +++ b/test/test-model-graph-editor-extension.jl @@ -0,0 +1,356 @@ +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=Required(Float64),) +PlantSimEngine.outputs_(::EditorSourceModel) = (signal=-Inf,) + +struct EditorConsumerModel <: AbstractEditorConsumerModel end +PlantSimEngine.inputs_(::EditorConsumerModel) = (signal=Required(Float64),) +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, on=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, on=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" => Any[], + "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).config.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, on=Many(scale=:Leaf), environment=Environment((provider=:model,)), output_routing=(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=Environment((provider = :model,))", code) + @test occursin("output_routing=(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, on=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..515a5017a --- /dev/null +++ b/test/test-model-graph-view.jl @@ -0,0 +1,615 @@ +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=Required(Float64),) +PlantSimEngine.outputs_(::ModelGraphSourceModel) = (signal=-Inf,) + +struct ModelGraphConsumerModel <: AbstractModelGraphConsumerModel end +PlantSimEngine.inputs_(::ModelGraphConsumerModel) = (signal=Required(Float64),) +PlantSimEngine.outputs_(::ModelGraphConsumerModel) = (result=-Inf,) + +struct ModelGraphCycleAModel <: AbstractModelGraphCycleAModel end +PlantSimEngine.inputs_(::ModelGraphCycleAModel) = (y=Required(Float64),) +PlantSimEngine.outputs_(::ModelGraphCycleAModel) = (x=-Inf,) + +struct ModelGraphCycleBModel <: AbstractModelGraphCycleBModel end +PlantSimEngine.inputs_(::ModelGraphCycleBModel) = (x=Required(Float64),) +PlantSimEngine.outputs_(::ModelGraphCycleBModel) = (y=-Inf,) + +struct ModelGraphEnvironmentModel <: AbstractModelGraphEnvironmentModel end +PlantSimEngine.inputs_(::ModelGraphEnvironmentModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelGraphEnvironmentModel) = (result=-Inf,) +PlantSimEngine.environment_inputs_(::ModelGraphEnvironmentModel) = (T=-Inf,) +PlantSimEngine.environment_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"]["declaration"] == "required" + @test descriptor["inputs"]["driver"]["expectedType"] == "Float64" + @test isnothing(descriptor["inputs"]["driver"]["default"]) + @test descriptor["outputs"]["signal"] == "-Inf" + @test descriptor["constructor"]["hasZeroArgConstructor"] + @test descriptor["constructor"]["fields"][1]["name"] == "coefficient" +end + +@testset "CompositeModel selector diagnostics preserve normalized fields" begin + selector = Many( + Relation(:parent); + within=SceneScope(), + kind=:plant, + species=:oil_palm, + scale=:Leaf, + name=:leaf_1, + process=:energy_balance, + application=:sunlit_energy, + var=:temperature, + policy=Integrate(), + window=2.0, + from_status=true, + after=("radiation", :water), + ) + payload = PlantSimEngine._model_graph_selector_criteria(selector) + + @test Set(keys(payload)) == Set([ + "selectors", + "within", + "kind", + "species", + "scale", + "name", + "process", + "application", + "var", + "policy", + "window", + "from_status", + "after", + ]) + @test only(payload["selectors"]) == Dict( + "type" => "Relation", + "relation" => "parent", + ) + @test payload["within"] == Dict("type" => "SceneScope") + @test payload["after"] == ["radiation", "water"] +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, on=One(name=:leaf)), + ModelSpec(ModelGraphConsumerModel(); name=:consumer, on=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, on=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, on=One(name=:leaf)), + ModelSpec(ModelGraphCycleBModel(); name=:cycle_b, on=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, on=One(name=:leaf), inputs=(PreviousTimeStep(:y) => One(within=Self(), var=:y))), + ModelSpec(ModelGraphCycleBModel(); name=:cycle_b, on=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, on=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"] == "required" + @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, on=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, on=One(name=:leaf)), + ModelSpec(ModelGraphCycleBModel(); name=:cycle_b, on=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, on=One(name=:leaf)), + ModelSpec(ModelGraphCycleBModel(); name=:cycle_b, on=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"] == "required" + 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, on=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).config == + (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, on=One(name=:leaf)), + ModelSpec(ModelGraphSourceModel(2.0); name=:second_writer, on=One(name=:leaf), updates=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, on=One(name=:leaf), environment=Environment(provider=:forcing, sink=:canopy)), + ), + ) + 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) + environment_input_edge = only( + edge for edge in environment_edges if edge["sourceVariable"] == "T" + ) + environment_output_edge = only( + edge for edge in environment_edges if edge["sourceVariable"] == "leaf_temperature" + ) + @test environment_input_edge["provider"] == "forcing" + @test environment_output_edge["provider"] == "canopy" + @test environment_output_edge["sink"] == "canopy" + + 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, on=One(name=:source_object)), + ModelSpec(ModelGraphConsumerModel(); name=:consumer, on=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, on=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..d4122c57d --- /dev/null +++ b/test/test-model-hard-calls.jl @@ -0,0 +1,352 @@ +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, + status, + environment, + constants, + context, +) + 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, + status, + environment, + constants, + context, +) + status.calls += 1 + leaf = only(run_call!(context, :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, + status, + environment, + constants, + context, +) + status.calls += 1 + middle = only(call_targets(context, :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, + status, + environment, + constants, + context, +) + targets = run_call!(context, :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, + status, + environment, + constants, + context, +) + one_targets = run_call!(context, :one; publish=false) + optional_targets = run_call!(context, :optional; publish=false) + many_targets = run_call!(context, :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(context, :one) === call_targets(context, :one) + CALL_RETURN_CONTEXT[] = context + 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, on=One(name=:scene), calls=(:middle => One(name=:middle, within=Subtree(), application=:middle)), every=Hour(1)), + ModelSpec(NestedCallMiddleModel(); name=:middle, on=One(name=:middle), calls=(:leaf => One(name=:leaf, within=Subtree(), application=:leaf)), every=Hour(1)), + ModelSpec(NestedCallLeafModel(); name=:leaf, on=One(name=:leaf), every=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, on=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, on=Many(scale=:Leaf)), + ), + environment=(duration=Hour(1),), + ) + + simulation = 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 + one_call_view = call_targets(context, :one) + @test length(one_call_view.execution_targets) == 1 + cached_execution_target = only(values(one_call_view.execution_targets)) + continue!(simulation) + continued_call_view = call_targets(CALL_RETURN_CONTEXT[], :one) + @test continued_call_view === one_call_view + @test only(values(continued_call_view.execution_targets)) === + cached_execution_target + register_object!( + model, + Object(:leaf_c; scale=:Leaf, name=:leaf_c, parent=:scene), + ) + continue!(simulation) + refreshed_call_view = call_targets(CALL_RETURN_CONTEXT[], :one) + @test only(values(refreshed_call_view.execution_targets)) !== + cached_execution_target + @test_throws ArgumentError run_call!(nothing, :one) + + undeclared = CompositeModel( + Object(:scene; scale=:Scene); + applications=( + ModelSpec(CallReturnShapeModel(); name=:controller, on=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, on=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, on=One(scale=:Scene), calls=(:one => One(scale=:Leaf, application=:leaf_calls))), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls, on=Many(scale=:Leaf)), + ), + ) + @test_throws "Expected exactly one object" Advanced.refresh_bindings!(multiple_one) + + @test_throws "`process=` in scenario" ModelSpec( + CallReturnShapeModel(); + name=:controller, + on=One(scale=:Scene), + calls=(:one => One(scale=:Leaf, process=:nested_call_leaf),), + ) + @test_throws "`process=` in scenario" ModelSpec( + CallReturnShapeModel(); + name=:controller, + on=One(scale=:Scene), + calls=( + :optional => + OptionalOne(scale=:Leaf, process=:nested_call_leaf), + ), + ) +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, on=One(name=:scene), calls=(:children => Many( + scale=:Leaf, + within=SceneScope(), + application=:leaf_calls, + ),)), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls, on=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, on=One(name=:plant_a), calls=(:children => Many( + scale=:Leaf, + within=Subtree(), + application=:leaf_calls, + ),)), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls, on=Many(scale=:Leaf)), + ), + environment=(duration=Hour(1),), + ) + + simulation = run!(model) + controller = only(model_objects(model; name=:plant_a)).status + schedule = Dict(row.application_id => row for row in explain_schedule(simulation.compiled)) + @test schedule[:leaf_calls].manual_call_only + @test !schedule[:leaf_calls].root_scheduled + @test controller.ncalls == 0 + + reparent_object!(model, :leaf, :plant_a) + continue!(simulation; steps=1) + @test controller.ncalls == 1 + @test controller.total == 1.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, on=One(name=:scene), calls=(:children => One(name=:leaf, application=:leaf_calls)), every=Day(1)), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls, on=One(name=:leaf), every=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, on=One(name=:scene), calls=(:children => One(name=:leaf, application=:leaf_calls)), every=Day(1)), + ModelSpec(NestedCallLeafModel(); name=:leaf_calls, on=One(name=:leaf)), + ), + environment=(duration=Hour(1),), + ) + @test_nowarn Advanced.refresh_bindings!(inherited) +end diff --git a/test/test-model-multirate-integration.jl b/test/test-model-multirate-integration.jl new file mode 100644 index 000000000..35cba48d0 --- /dev/null +++ b/test/test-model-multirate-integration.jl @@ -0,0 +1,81 @@ +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=Required(Float64),) +PlantSimEngine.outputs_(::HourlyLeafFluxModel) = (flux=0.0, hourly_runs=0) +function PlantSimEngine.run!(::HourlyLeafFluxModel, status, environment, constants, context) + status.flux = status.rate + status.hourly_runs += 1 +end +PlantSimEngine.inputs_(::DailyPlantFluxModel) = (leaf_fluxes=Required(Vector{Float64}),) +PlantSimEngine.outputs_(::DailyPlantFluxModel) = (daily_total=0.0, daily_runs=0) +function PlantSimEngine.run!(::DailyPlantFluxModel, status, environment, constants, context) + 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, status, environment, constants, context) + 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, on=Many(scale=:Leaf), every=Hour(1)), + ModelSpec(DailyPlantFluxModel(); name=:daily_plant, on=Many(scale=:Plant), inputs=(:leaf_fluxes => Many( + scale=:Leaf, + within=Subtree(), + application=:hourly_flux, + var=:flux, + policy=Integrate(), + window=Day(1), + )), every=Day(1)), + ModelSpec(DailySoilStateModel(); name=:daily_soil, on=One(scale=:Soil), every=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..74bed18d0 --- /dev/null +++ b/test/test-model-numerical-parity.jl @@ -0,0 +1,225 @@ +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.environment_inputs_(::ParityForcingModel) = (forcing=0.0,) +function PlantSimEngine.run!(::ParityForcingModel, status, environment, constants, context) + status.var1 = environment.forcing + return nothing +end + +PlantSimEngine.inputs_(::ParityStageOneModel) = (var1=Required(Float64), var2=Required(Float64)) +PlantSimEngine.outputs_(::ParityStageOneModel) = (var3=0.0,) +function PlantSimEngine.run!(model::ParityStageOneModel, status, environment, constants, context) + status.var3 = model.a + status.var1 * status.var2 + return nothing +end + +PlantSimEngine.inputs_(::ParityStageTwoModel) = (var3=Required(Float64),) +PlantSimEngine.outputs_(::ParityStageTwoModel) = (raw_var4=0.0, var5=0.0) +PlantSimEngine.environment_inputs_(::ParityStageTwoModel) = (T=0.0, Wind=0.0, Rh=0.0) +function PlantSimEngine.run!(::ParityStageTwoModel, status, environment, constants, context) + status.raw_var4 = status.var3 * 2 + status.var5 = status.raw_var4 + environment.T + 2 * environment.Wind + 3 * environment.Rh + return nothing +end + +PlantSimEngine.inputs_(::ParityStageThreeModel) = (raw_var4=Required(Float64), var5=Required(Float64)) +PlantSimEngine.outputs_(::ParityStageThreeModel) = (var4=0.0, var6=0.0) +function PlantSimEngine.run!(::ParityStageThreeModel, status, environment, constants, context) + status.var4 = status.raw_var4 * 2 + status.var6 = status.var5 + status.var4 + return nothing +end + +PlantSimEngine.inputs_(::ParityStageFiveModel) = (var5=Required(Float64), var6=Required(Float64)) +PlantSimEngine.outputs_(::ParityStageFiveModel) = (var7=0.0,) +function PlantSimEngine.run!(::ParityStageFiveModel, status, environment, constants, context) + status.var7 = status.var5 * status.var6 +end +PlantSimEngine.inputs_(::ParityStageSixModel) = (var7=Required(Float64),) +PlantSimEngine.outputs_(::ParityStageSixModel) = (var8=0.0,) +function PlantSimEngine.run!(::ParityStageSixModel, status, environment, constants, context) + status.var8 = status.var7 + 1 +end +PlantSimEngine.inputs_(::ParitySoilModel) = NamedTuple() +PlantSimEngine.outputs_(::ParitySoilModel) = (soil_value=1.0,) +PlantSimEngine.run!(::ParitySoilModel, status, environment, constants, context) = nothing +PlantSimEngine.inputs_(::ParityGatherModel) = (leaf_values=Required(Vector{Float64}), soil_value=Required(Float64)) +PlantSimEngine.outputs_(::ParityGatherModel) = (gathered=0.0, scattered=0.0) +function PlantSimEngine.run!(::ParityGatherModel, status, environment, constants, context) + status.gathered = sum(status.leaf_values) + status.soil_value + status.scattered = first(status.leaf_values) +end +PlantSimEngine.inputs_(::ParityReceiverModel) = (shared=Required(Float64),) +PlantSimEngine.outputs_(::ParityReceiverModel) = (received=0.0,) +function PlantSimEngine.run!(::ParityReceiverModel, status, environment, constants, context) + 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, on=selector), + ModelSpec(ParityStageOneModel(1.0); name=:stage_one, on=selector), + ModelSpec(ParityStageTwoModel(); name=:stage_two, on=selector), + ModelSpec(ParityStageThreeModel(); name=:stage_three, on=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, on=One(scale=:Soil)), + ModelSpec(ParityForcingModel(); name=:forcing, on=Many(scale=:Leaf)), + ModelSpec(ParityStageOneModel(1.0); name=:stage_one, on=Many(scale=:Leaf)), + ModelSpec(ParityStageTwoModel(); name=:stage_two, on=Many(scale=:Leaf)), + ModelSpec(ParityStageThreeModel(); name=:stage_three, on=Many(scale=:Leaf)), + ModelSpec(ParityStageFiveModel(); name=:stage_five, on=Many(scale=:Leaf)), + ModelSpec(ParityStageSixModel(); name=:stage_six, on=Many(scale=:Leaf)), + ModelSpec(ParityGatherModel(); name=:plant_gather, on=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, on=Many(scale=:Leaf), inputs=(:shared => One( + scale=:Plant, + within=Ancestor(scale=:Plant), + application=:plant_gather, + var=:scattered, + ))), + ModelSpec(ParityReceiverModel(); name=:internode_receiver, on=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..0141023e7 --- /dev/null +++ b/test/test-model-output-boundaries.jl @@ -0,0 +1,152 @@ +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, status, environment, constants, context) + status.count += 1 + status.ignored += 10 +end + +PlantSimEngine.@process "boundary_manual_counter" verbose = false +PlantSimEngine.@process "boundary_manual_controller" verbose = false + +struct BoundaryManualCounterModel <: AbstractBoundary_Manual_CounterModel end +struct BoundaryManualControllerModel <: AbstractBoundary_Manual_ControllerModel end + +PlantSimEngine.inputs_(::BoundaryManualCounterModel) = NamedTuple() +PlantSimEngine.outputs_(::BoundaryManualCounterModel) = (count=0,) +function PlantSimEngine.run!( + ::BoundaryManualCounterModel, + status, + environment, + constants, + context, +) + status.count += 1 +end + +PlantSimEngine.inputs_(::BoundaryManualControllerModel) = NamedTuple() +PlantSimEngine.outputs_(::BoundaryManualControllerModel) = (step=0,) +function PlantSimEngine.run!( + ::BoundaryManualControllerModel, + status, + environment, + constants, + context, +) + status.step += 1 + status.step == 2 && run_call!(context, :counter; publish=true) +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, on=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 "held manual output spans the requested timeline" begin + model = CompositeModel( + Object(:scene; scale=:Scene), + Object(:leaf; scale=:Leaf, parent=:scene); + applications=( + ModelSpec(BoundaryManualControllerModel(); name=:controller, on=One(scale=:Scene), calls=(:counter => One( + scale=:Leaf, + application=:manual_counter, + ),)), + ModelSpec(BoundaryManualCounterModel(); name=:manual_counter, on=One(scale=:Leaf)), + ), + environment=[(duration=Hour(1),) for _ in 1:4], + ) + simulation = run!( + model; + steps=4, + outputs=OutputRequest( + :Leaf, + :count; + name=:held_manual_count, + application=:manual_counter, + policy=HoldLast(), + ), + ) + requested = collect_outputs( + simulation, + :held_manual_count; + sink=nothing, + ) + @test getproperty.(requested, :timestep) == [1, 2, 3, 4] + @test getproperty.(requested, :value) == [0, 1, 1, 1] + @test outputs(simulation)[ + (:manual_counter, ObjectId(:leaf), :count) + ] == [(2.0, 1)] +end + +@testset "implicit output request prefers the root-scheduled publisher" begin + model = CompositeModel( + Object(:scene; scale=:Scene), + Object(:leaf; scale=:Leaf, parent=:scene); + applications=( + ModelSpec(BoundaryManualControllerModel(); name=:controller, on=One(scale=:Scene), calls=(:counter => One( + scale=:Leaf, + application=:manual_counter, + ),)), + ModelSpec(BoundaryManualCounterModel(); name=:scheduled_counter, on=One(scale=:Leaf)), + ModelSpec(BoundaryManualCounterModel(); name=:manual_counter, on=One(scale=:Leaf)), + ), + environment=[(duration=Hour(1),) for _ in 1:2], + ) + simulation = run!( + model; + steps=2, + outputs=OutputRequest(:Leaf, :count; name=:scheduled_count), + ) + requested = collect_outputs(simulation, :scheduled_count; sink=nothing) + @test length(requested) == 2 + @test unique(getproperty.(requested, :application_id)) == [:scheduled_counter] +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, on=Many(scale=:Leaf), every=Hour(1)), + ModelSpec(BoundaryCounterModel(); name=:daily_plant, on=One(scale=:Plant), every=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-previous-timestep-views.jl b/test/test-model-previous-timestep-views.jl new file mode 100644 index 000000000..c9f0d0493 --- /dev/null +++ b/test/test-model-previous-timestep-views.jl @@ -0,0 +1,635 @@ +using PlantSimEngine +using Test + +PlantSimEngine.@process "temporal_view_signal_source" verbose = false + +struct TemporalViewSignalSource <: AbstractTemporal_View_Signal_SourceModel end + +PlantSimEngine.inputs_(::TemporalViewSignalSource) = NamedTuple() +PlantSimEngine.outputs_(::TemporalViewSignalSource) = (signal=0.0,) + +function PlantSimEngine.run!( + ::TemporalViewSignalSource, + status, + environment, + constants=nothing, + context=nothing, +) + status.signal += 1.0 + return nothing +end + +PlantSimEngine.@process "temporal_view_signal_consumer" verbose = false + +struct TemporalViewSignalConsumer <: AbstractTemporal_View_Signal_ConsumerModel end + +PlantSimEngine.inputs_(::TemporalViewSignalConsumer) = (signal=Required(Float64), gain=Required(Float64)) +PlantSimEngine.outputs_(::TemporalViewSignalConsumer) = (observed_signal=0.0,) + +function PlantSimEngine.run!( + ::TemporalViewSignalConsumer, + status, + environment, + constants=nothing, + context=nothing, +) + status.observed_signal = status.signal * status.gain + status.signal = -999.0 + return nothing +end + +PlantSimEngine.@process "temporal_view_same_step_consumer" verbose = false + +struct TemporalViewSameStepConsumer <: + AbstractTemporal_View_Same_Step_ConsumerModel end + +PlantSimEngine.inputs_(::TemporalViewSameStepConsumer) = (signal=Required(Float64),) +PlantSimEngine.outputs_(::TemporalViewSameStepConsumer) = + (observed_signal=0.0,) + +function PlantSimEngine.run!( + ::TemporalViewSameStepConsumer, + status, + environment, + constants=nothing, + context=nothing, +) + status.observed_signal = status.signal + return nothing +end + +PlantSimEngine.@process "temporal_view_many_consumer" verbose = false + +struct TemporalViewManyConsumer <: AbstractTemporal_View_Many_ConsumerModel end + +PlantSimEngine.inputs_(::TemporalViewManyConsumer) = (signals=Required(Vector{Float64}),) +PlantSimEngine.outputs_(::TemporalViewManyConsumer) = (signal_total=0.0,) + +function PlantSimEngine.run!( + ::TemporalViewManyConsumer, + status, + environment, + constants=nothing, + context=nothing, +) + status.signal_total = sum(status.signals) + status.signals .= -999.0 + return nothing +end + +PlantSimEngine.@process "temporal_view_cycle_a" verbose = false +PlantSimEngine.@process "temporal_view_cycle_b" verbose = false + +struct TemporalViewCycleA <: AbstractTemporal_View_Cycle_AModel end +struct TemporalViewCycleB <: AbstractTemporal_View_Cycle_BModel end + +PlantSimEngine.inputs_(::TemporalViewCycleA) = (cycle_b=Required(Float64),) +PlantSimEngine.outputs_(::TemporalViewCycleA) = (cycle_a=0.0,) +PlantSimEngine.inputs_(::TemporalViewCycleB) = (cycle_a=Required(Float64),) +PlantSimEngine.outputs_(::TemporalViewCycleB) = (cycle_b=0.0,) + +function PlantSimEngine.run!( + ::TemporalViewCycleA, + status, + environment, + constants=nothing, + context=nothing, +) + status.cycle_a = status.cycle_b + 1.0 + return nothing +end + +function PlantSimEngine.run!( + ::TemporalViewCycleB, + status, + environment, + constants=nothing, + context=nothing, +) + status.cycle_b = 2.0 * status.cycle_a + return nothing +end + +PlantSimEngine.@process "temporal_view_ambiguous_overlap" verbose = false + +struct TemporalViewAmbiguousOverlap <: + AbstractTemporal_View_Ambiguous_OverlapModel end + +PlantSimEngine.inputs_(::TemporalViewAmbiguousOverlap) = (signal=Required(Float64),) +PlantSimEngine.outputs_(::TemporalViewAmbiguousOverlap) = (signal=0.0,) + +function _temporal_view_source_spec(; target=One(scale=:Leaf)) + return ModelSpec(TemporalViewSignalSource(); name=:signal_source, on=target) +end + +function _temporal_view_consumer_spec(; target=One(scale=:Leaf), selector) + return ModelSpec(TemporalViewSignalConsumer(); name=:lagged_consumer, on=target, inputs=(PreviousTimeStep(:signal) => selector)) +end + +function _materialization_allocations( + compiled, + application, + status_view, + streams, + time, +) + return @allocated PlantSimEngine._materialize_model_inputs!( + status_view.status, + status_view.temporal_inputs, + compiled, + application, + streams, + time, + ) +end + +@testset "Application-local PreviousTimeStep status views" begin + source_spec = _temporal_view_source_spec() + consumer_spec = _temporal_view_consumer_spec( + selector=One( + within=Self(), + application=:signal_source, + var=:signal, + ), + ) + same_object = CompositeModel( + Object( + :leaf; + scale=:Leaf, + status=Status(signal=5.0, gain=2.0, observed_signal=0.0), + ); + applications=(source_spec, consumer_spec), + ) + compiled = Advanced.refresh_bindings!(same_object) + @test compiled.application_order == [:signal_source, :lagged_consumer] + + lagged_binding = only( + binding for binding in compiled.input_bindings + if binding.application_id == :lagged_consumer && + binding.input == :signal + ) + children = Dict{Symbol,Set{Symbol}}() + PlantSimEngine._model_input_order_edges!( + children, + (lagged_binding,), + Dict{Symbol,Set{Symbol}}(), + ) + @test isempty(children) + + status_view = compiled.status_views_by_target[ + (:lagged_consumer, ObjectId(:leaf)) + ] + canonical_status = only(model_objects(same_object)).status + @test status_view.status !== canonical_status + @test PlantSimEngine.refvalue(status_view.status, :signal) !== + PlantSimEngine.refvalue(canonical_status, :signal) + @test PlantSimEngine.refvalue(status_view.status, :gain) === + PlantSimEngine.refvalue(canonical_status, :gain) + @test PlantSimEngine.refvalue(status_view.status, :observed_signal) === + PlantSimEngine.refvalue(canonical_status, :observed_signal) + + simulation = run!( + same_object; + steps=3, + outputs=OutputRequest( + :Leaf, + :observed_signal; + name=:observed_signal, + application=:lagged_consumer, + ), + ) + @test canonical_status.signal == 8.0 + @test canonical_status.observed_signal == 14.0 + @test getproperty.( + collect_outputs( + simulation, + :leaf, + :observed_signal; + sink=nothing, + ), + :value, + ) == [10.0, 12.0, 14.0] + dependency_stream = outputs(simulation)[ + (:signal_source, ObjectId(:leaf), :signal) + ] + @test dependency_stream isa PlantSimEngine.TemporalDependencyBuffer{Float64} + @test length(dependency_stream.times) == 2 + @test dependency_stream == [(2.0, 7.0), (3.0, 8.0)] + retention = only( + row for row in explain_output_retention(simulation) + if row.application_id == :signal_source + ) + @test retention.reasons == (:temporal_dependency,) + @test retention.retention_steps == 2.0 + + execution_target = only( + only( + batch.targets for batch in simulation.execution_plan.batches + if batch.application.id == :lagged_consumer + ), + ) + @test execution_target.status === status_view.status + runtime_temporal_input = only(execution_target.input_bindings) + @test runtime_temporal_input isa PlantSimEngine.RuntimeTemporalInput + @test runtime_temporal_input.compiled === only(status_view.temporal_inputs) + @test runtime_temporal_input.source_streams === dependency_stream + @test isempty(execution_target.output_bindings) + source_execution_target = only( + only( + batch.targets for batch in simulation.execution_plan.batches + if batch.application.id == :signal_source + ), + ) + runtime_output = only(source_execution_target.output_bindings) + @test PlantSimEngine._runtime_output_variable(runtime_output) == + :signal + @test runtime_output.stream === dependency_stream + @test runtime_output.reference === + PlantSimEngine.refvalue(canonical_status, :signal) + materialized_status = PlantSimEngine._materialize_model_inputs!( + simulation.compiled, + simulation.compiled.applications_by_id[:lagged_consumer], + ObjectId(:leaf), + simulation.temporal_streams, + 4, + ) + @test materialized_status === status_view.status + _materialization_allocations( + simulation.compiled, + simulation.compiled.applications_by_id[:lagged_consumer], + status_view, + simulation.temporal_streams, + 4, + ) + scalar_materialization_allocations = _materialization_allocations( + simulation.compiled, + simulation.compiled.applications_by_id[:lagged_consumer], + status_view, + simulation.temporal_streams, + 4, + ) + @test scalar_materialization_allocations < + Base.summarysize(status_view.status) + + Base.summarysize(status_view.temporal_inputs) + + consumer_first = CompositeModel( + Object( + :leaf; + scale=:Leaf, + status=Status(signal=5.0, gain=2.0, observed_signal=0.0), + ); + applications=(consumer_spec, source_spec), + ) + @test Advanced.refresh_bindings!(consumer_first).application_order == + [:lagged_consumer, :signal_source] + + cycle_status = Status(cycle_a=0.0, cycle_b=2.0) + unbroken_cycle = CompositeModel( + Object(:leaf; scale=:Leaf, status=cycle_status); + applications=( + ModelSpec(TemporalViewCycleA(); name=:cycle_a, on=One(scale=:Leaf)), + ModelSpec(TemporalViewCycleB(); name=:cycle_b, on=One(scale=:Leaf)), + ), + ) + @test_throws "dependency cycle" Advanced.refresh_bindings!(unbroken_cycle) + + opened_cycle = CompositeModel( + Object( + :leaf; + scale=:Leaf, + status=Status(cycle_a=0.0, cycle_b=2.0), + ); + applications=( + ModelSpec(TemporalViewCycleA(); name=:cycle_a, on=One(scale=:Leaf), inputs=(PreviousTimeStep(:cycle_b) => One( + within=Self(), + application=:cycle_b, + var=:cycle_b, + ),)), + ModelSpec(TemporalViewCycleB(); name=:cycle_b, on=One(scale=:Leaf)), + ), + ) + opened_simulation = run!(opened_cycle; steps=3) + opened_status = only(model_objects(opened_cycle)).status + @test opened_status.cycle_a == 15.0 + @test opened_status.cycle_b == 30.0 + @test haskey( + outputs(opened_simulation), + (:cycle_b, ObjectId(:leaf), :cycle_b), + ) + + cross_object = CompositeModel( + Object(:scene; scale=:Scene), + Object( + :plant; + scale=:Plant, + parent=:scene, + status=Status(signal=0.0, gain=1.0, observed_signal=0.0), + ), + Object( + :leaf; + scale=:Leaf, + parent=:plant, + status=Status(signal=10.0), + ); + applications=( + source_spec, + _temporal_view_consumer_spec( + target=One(scale=:Plant), + selector=One( + scale=:Leaf, + within=Subtree(), + application=:signal_source, + var=:signal, + ), + ), + ), + ) + cross_simulation = run!( + cross_object; + steps=2, + outputs=OutputRequest( + :Plant, + :observed_signal; + name=:cross_observed, + application=:lagged_consumer, + ), + ) + @test only(model_objects(cross_object; scale=:Leaf)).status.signal == 12.0 + @test getproperty.( + collect_outputs( + cross_simulation, + :plant, + :observed_signal; + sink=nothing, + ), + :value, + ) == [10.0, 11.0] + + many_object = CompositeModel( + Object( + :scene; + scale=:Scene, + status=Status(signals=[0.0, 0.0], signal_total=0.0), + ), + Object( + :leaf_1; + scale=:Leaf, + parent=:scene, + status=Status(signal=1.0), + ), + Object( + :leaf_2; + scale=:Leaf, + parent=:scene, + status=Status(signal=2.0), + ); + applications=( + _temporal_view_source_spec(target=Many(scale=:Leaf)), + ModelSpec(TemporalViewManyConsumer(); name=:many_consumer, on=One(scale=:Scene), inputs=(PreviousTimeStep(:signals) => Many( + scale=:Leaf, + within=SceneScope(), + application=:signal_source, + var=:signal, + ),)), + ), + ) + many_simulation = run!( + many_object; + steps=2, + outputs=OutputRequest( + :Scene, + :signal_total; + name=:many_total, + application=:many_consumer, + ), + ) + @test [ + object.status.signal + for object in model_objects(many_object; scale=:Leaf) + ] == [3.0, 4.0] + @test getproperty.( + collect_outputs( + many_simulation, + :scene, + :signal_total; + sink=nothing, + ), + :value, + ) == [3.0, 5.0] + many_view = many_simulation.compiled.status_views_by_target[ + (:many_consumer, ObjectId(:scene)) + ] + many_storage = many_view.status.signals + PlantSimEngine._materialize_model_inputs!( + many_view.status, + many_view.temporal_inputs, + many_simulation.compiled, + many_simulation.compiled.applications_by_id[:many_consumer], + many_simulation.temporal_streams, + 3, + ) + @test many_view.status.signals === many_storage + many_materialization_allocations = _materialization_allocations( + many_simulation.compiled, + many_simulation.compiled.applications_by_id[:many_consumer], + many_view, + many_simulation.temporal_streams, + 3, + ) + @test many_materialization_allocations < + Base.summarysize(many_view.status) + + Base.summarysize(many_view.temporal_inputs) + + dynamic_object = CompositeModel( + Object( + :scene; + scale=:Scene, + status=Status(signals=[0.0], signal_total=0.0), + ), + Object( + :leaf_1; + scale=:Leaf, + parent=:scene, + status=Status(signal=1.0), + ); + applications=( + _temporal_view_source_spec(target=Many(scale=:Leaf)), + ModelSpec(TemporalViewManyConsumer(); name=:many_consumer, on=One(scale=:Scene), inputs=(PreviousTimeStep(:signals) => Many( + scale=:Leaf, + within=SceneScope(), + application=:signal_source, + var=:signal, + ),)), + ), + ) + dynamic_simulation = run!( + dynamic_object; + outputs=OutputRequest( + :Scene, + :signal_total; + name=:dynamic_total, + application=:many_consumer, + ), + ) + register_object!( + dynamic_object, + Object(:leaf_2; scale=:Leaf, status=Status(signal=10.0)); + parent=:scene, + ) + continue!(dynamic_simulation; steps=2) + @test getproperty.( + collect_outputs( + dynamic_simulation, + :scene, + :signal_total; + sink=nothing, + ), + :value, + ) == [1.0, 12.0, 14.0] + dynamic_view = dynamic_simulation.compiled.status_views_by_target[ + (:many_consumer, ObjectId(:scene)) + ] + @test length(dynamic_view.status.signals) == 2 + @test only( + only( + batch.targets for batch in dynamic_simulation.execution_plan.batches + if batch.application.id == :many_consumer + ), + ).status === dynamic_view.status + + dynamic_same_step = CompositeModel( + Object(:scene; scale=:Scene); + applications=( + ModelSpec(TemporalViewSameStepConsumer(); + name=:same_step_consumer, on=Many(scale=:Leaf)), + _temporal_view_source_spec(target=Many(scale=:Leaf)), + ), + ) + initial_dynamic_order = + Advanced.refresh_bindings!(dynamic_same_step).application_order + @test initial_dynamic_order == + [:same_step_consumer, :signal_source] + dynamic_same_step_simulation = + run!(dynamic_same_step; outputs=:none) + register_object!( + dynamic_same_step, + Object( + :leaf; + scale=:Leaf, + status=Status(signal=5.0, observed_signal=0.0), + ); + parent=:scene, + ) + continue!(dynamic_same_step_simulation) + @test dynamic_same_step_simulation.compiled.application_order == + [:signal_source, :same_step_consumer] + dynamic_same_step_status = + only(model_objects(dynamic_same_step; scale=:Leaf)).status + @test dynamic_same_step_status.signal == 6.0 + @test dynamic_same_step_status.observed_signal == 6.0 + + dynamic_temporal_target = CompositeModel( + Object(:scene; scale=:Scene); + applications=( + _temporal_view_source_spec(target=Many(scale=:Leaf)), + _temporal_view_consumer_spec( + target=Many(scale=:Leaf), + selector=One( + within=Self(), + application=:signal_source, + var=:signal, + ), + ), + ), + ) + dynamic_temporal_simulation = + run!(dynamic_temporal_target; outputs=:none) + register_object!( + dynamic_temporal_target, + Object( + :leaf; + scale=:Leaf, + status=Status(signal=5.0, gain=1.0, observed_signal=0.0), + ); + parent=:scene, + ) + continue!(dynamic_temporal_simulation; steps=2) + dynamic_temporal_status = + only(model_objects(dynamic_temporal_target; scale=:Leaf)).status + @test dynamic_temporal_status.signal == 7.0 + @test dynamic_temporal_status.observed_signal == 6.0 + @test dynamic_temporal_simulation.output_retention.temporal_dependencies == + Set([(:signal_source, :signal)]) + @test haskey( + outputs(dynamic_temporal_simulation), + (:signal_source, ObjectId(:leaf), :signal), + ) + @test outputs(dynamic_temporal_simulation)[ + (:signal_source, ObjectId(:leaf), :signal) + ] isa PlantSimEngine.TemporalDependencyBuffer{Float64} + + ambiguous_overlap = CompositeModel( + Object(:leaf; scale=:Leaf, status=Status(signal=1.0)); + applications=( + ModelSpec(TemporalViewAmbiguousOverlap(); + name=:ambiguous_overlap, on=One(scale=:Leaf), inputs=(PreviousTimeStep(:signal) => One( + within=Self(), + var=:signal, + ),)), + ), + ) + @test_throws "both a temporal input and an output" Advanced.refresh_bindings!( + ambiguous_overlap, + ) +end + +@testset "PreviousTimeStep dependency storage scales by object, not duration" begin + object_count = 2_000 + objects = [ + Object( + Symbol(:leaf_, index); + scale=:Leaf, + status=Status( + signal=0.0, + gain=1.0, + observed_signal=0.0, + ), + ) + for index in 1:object_count + ] + scene = CompositeModel( + objects...; + applications=( + _temporal_view_source_spec(target=Many(scale=:Leaf)), + _temporal_view_consumer_spec( + target=Many(scale=:Leaf), + selector=One( + within=Self(), + application=:signal_source, + var=:signal, + ), + ), + ), + ) + simulation = run!(scene; steps=3, outputs=:none) + streams_by_key = copy(outputs(simulation)) + streams = collect(values(streams_by_key)) + @test length(streams) == object_count + @test all( + stream -> stream isa PlantSimEngine.TemporalDependencyBuffer{Float64}, + streams, + ) + @test all(stream -> length(stream) == 2, streams) + @test all(stream -> length(stream.times) == 2, streams) + + continue!(simulation) + steady_allocations = @allocated continue!(simulation) + @test steady_allocations <= 4_096 * object_count + + continue!(simulation; steps=16) + @test all( + outputs(simulation)[key] === stream + for (key, stream) in streams_by_key + ) + @test all(stream -> length(stream) == 2, streams) +end diff --git a/test/test-model-runtime-matrix.jl b/test/test-model-runtime-matrix.jl new file mode 100644 index 000000000..53bacfe32 --- /dev/null +++ b/test/test-model-runtime-matrix.jl @@ -0,0 +1,97 @@ +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.environment_inputs_(::RuntimeMatrixProbeModel) = (T=0.0,) +function PlantSimEngine.run!(::RuntimeMatrixProbeModel, status, environment, constants, context) + status.seen = environment.T +end + +function runtime_matrix_scene(environment) + return CompositeModel( + Object(:probe; scale=:Leaf); + applications=( + ModelSpec(RuntimeMatrixProbeModel(); name=:probe, on=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, on=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..45f441da6 --- /dev/null +++ b/test/test-model-status-initialization.jl @@ -0,0 +1,130 @@ +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, status, environment, constants, context) + status.signal += 1 +end + +PlantSimEngine.inputs_(::InitializationConsumerModel) = ( + signal=Required(Int), + supplied=Required(Int), + offset=Default(0), +) +PlantSimEngine.outputs_(::InitializationConsumerModel) = (observed=0,) +function PlantSimEngine.run!(::InitializationConsumerModel, status, environment, constants, context) + status.observed = status.signal + status.supplied + status.offset +end + +@testset "generated and partial status" begin + model = CompositeModel( + Object(:object; scale=:Leaf, status=Status(supplied=40)); + applications=( + ModelSpec(InitializationSourceModel(); name=:source, on=One(scale=:Leaf)), + ModelSpec(InitializationConsumerModel(); name=:consumer, on=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, :offset, :observed)) + @test status.offset == 0 + 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 + default_row = only(row for row in report if row.variable == :offset && row.role == :input) + @test default_row.disposition == :defaulted + @test default_row.declaration == :defaulted + @test default_row.default_value == 0 + run!(model) + @test status.observed == 42 + + unresolved = CompositeModel(InitializationConsumerModel(); status=(supplied=1,)) + @test isnothing(only(model_objects(unresolved)).kind) + unresolved_report = explain_initialization(unresolved) + @test any(row -> row.variable == :signal && row.disposition == :required, + unresolved_report) + @test any(row -> row.variable == :offset && row.disposition == :defaulted, + unresolved_report) + @test_throws "Missing required composite-model/object input" run!(unresolved) +end + +PlantSimEngine.@process "invalid_initialization_schema" verbose = false +PlantSimEngine.@process "default_vector_initialization" verbose = false +PlantSimEngine.@process "generic_required_initialization" verbose = false +struct InvalidInitializationSchemaModel <: AbstractInvalid_Initialization_SchemaModel end +struct DefaultVectorInitializationModel <: AbstractDefault_Vector_InitializationModel end +struct GenericRequiredInitializationModel <: AbstractGeneric_Required_InitializationModel end +PlantSimEngine.inputs_(::InvalidInitializationSchemaModel) = (legacy_literal=0.0,) +PlantSimEngine.outputs_(::InvalidInitializationSchemaModel) = NamedTuple() +PlantSimEngine.inputs_(::DefaultVectorInitializationModel) = (buffer=Default(Int[]),) +PlantSimEngine.outputs_(::DefaultVectorInitializationModel) = NamedTuple() +PlantSimEngine.inputs_(::GenericRequiredInitializationModel) = (value=Required(Real),) +PlantSimEngine.outputs_(::GenericRequiredInitializationModel) = NamedTuple() + +@testset "explicit input declarations" begin + @test inputs(InitializationConsumerModel()) == (:signal, :supplied, :offset) + @test init_variables(InitializationConsumerModel()) == (offset=0, observed=0) + typed = PlantSimEngine.variables_typed(InitializationConsumerModel()) + @test typed.signal == Int + @test typed.supplied == Int + @test typed.offset == Int + @test typed.observed == Int + PlantSimEngine._input_schema(InitializationConsumerModel()) + @test @allocated( + PlantSimEngine._input_schema(InitializationConsumerModel()) + ) == 0 + invalid = CompositeModel( + InvalidInitializationSchemaModel(); + status=(legacy_literal=1.0,), + ) + @test_throws "must make every input explicit" Advanced.refresh_bindings!(invalid) + + private_defaults = CompositeModel( + Object(:leaf_1; scale=:Leaf), + Object(:leaf_2; scale=:Leaf); + applications=( + ModelSpec(DefaultVectorInitializationModel(); name=:default_vector, on=Many(scale=:Leaf)), + ), + ) + Advanced.refresh_bindings!(private_defaults) + buffers = [object.status.buffer for object in model_objects(private_defaults)] + @test buffers[1] !== buffers[2] + push!(buffers[1], 1) + @test isempty(buffers[2]) + + generic_required = CompositeModel( + GenericRequiredInitializationModel(); + status=(value=big"1.25",), + ) + Advanced.refresh_bindings!(generic_required) + generic_status = only(model_objects(generic_required)).status + @test generic_status.value isa BigFloat + @test PlantSimEngine.variables_typed(GenericRequiredInitializationModel()).value === Real + + optional_default = CompositeModel( + Object( + :leaf; + scale=:Leaf, + status=Status(signal=1, supplied=2), + ); + applications=( + ModelSpec(InitializationConsumerModel(); name=:consumer, on=One(scale=:Leaf), inputs=(:offset => OptionalOne(scale=:Soil, var=:offset))), + ), + ) + optional_row = only( + row for row in explain_initialization(optional_default) + if row.role == :input && row.variable == :offset + ) + @test optional_row.disposition == :defaulted + @test optional_row.origin == :model_default +end diff --git a/test/test-model-temporal-reducers.jl b/test/test-model-temporal-reducers.jl new file mode 100644 index 000000000..3e6b7c455 --- /dev/null +++ b/test/test-model-temporal-reducers.jl @@ -0,0 +1,117 @@ +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, status, environment, constants, context) + status.signal += 1 +end +PlantSimEngine.inputs_(::Union{TemporalReducerOneArgModel,TemporalReducerTwoArgModel}) = (reduced=Required(Float64),) +PlantSimEngine.outputs_(::TemporalReducerOneArgModel) = (one_arg=0.0,) +PlantSimEngine.outputs_(::TemporalReducerTwoArgModel) = (two_arg=0.0,) +PlantSimEngine.run!(::TemporalReducerOneArgModel, status, environment, constants, context) = + (status.one_arg = status.reduced) +PlantSimEngine.run!(::TemporalReducerTwoArgModel, status, environment, constants, context) = + (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, on=One(scale=:Leaf), every=Hour(1)), + ModelSpec(TemporalReducerOneArgModel(); name=:one_arg, on=One(scale=:Leaf), inputs=(:reduced => One( + scale=:Leaf, + application=:source, + var=:signal, + policy=Aggregate(one_arg), + window=Hour(3), + )), every=Hour(3)), + ModelSpec(TemporalReducerTwoArgModel(); name=:two_arg, on=One(scale=:Leaf), inputs=(:reduced => One( + scale=:Leaf, + application=:source, + var=:signal, + policy=Aggregate(two_arg), + window=Hour(3), + )), every=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, on=One(scale=:Leaf)), + ModelSpec(TemporalReducerOneArgModel(); name=:consumer, on=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, on=One(scale=:Leaf), every=Hour(2)), + ModelSpec(TemporalReducerTwoArgModel(); name=:integral, on=One(scale=:Leaf), inputs=(:reduced => One( + scale=:Leaf, + application=:source, + var=:signal, + policy=Aggregate(integral), + window=Hour(4), + )), every=Hour(4)), + ModelSpec(TemporalReducerOneArgModel(); name=:weighted_mean, on=One(scale=:Leaf), inputs=(:reduced => One( + scale=:Leaf, + application=:source, + var=:signal, + policy=Aggregate(weighted_mean), + window=Hour(4), + )), every=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..b5bf9809d --- /dev/null +++ b/test/test-model-time-validation.jl @@ -0,0 +1,97 @@ +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, status, environment, constants, context) + 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, status, environment, constants, context) + status.count += 1 +end + +function time_validation_scene(environment; cadence=nothing) + spec = ModelSpec( + TimeValidationCounterModel(); + name=:counter, + on=One(scale=:Scene), + every=cadence, + ) + return CompositeModel( + Object(:scene; scale=:Scene); + applications=(spec,), + environment=environment, + ) +end + +@testset "environment duration validation" begin + @test_throws "Missing required `duration` in environment row 1" Advanced.refresh_bindings!( + time_validation_scene([(T=20.0,)]) + ) + @test_throws "environment 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 environment 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, on=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..86e620b37 100644 --- a/test/test-toy_models.jl +++ b/test/test-toy_models.jl @@ -1,106 +1,1335 @@ -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),), +function toy_scene(status, models...; environment=nothing) + applications = map(models) do model + ModelSpec(model; on=One(scale=:Plant)) + end + CompositeModel( + Object(:plant; scale=:Plant, kind=:plant, status=status); + applications=Tuple(applications), + environment=environment, + ) +end + +@testset "Toy model declarations" begin + models = ( + ToyDegreeDaysCumulModel(), + ToyLAIModel(), + Beer(0.5), + ToyRUEGrowthModel(0.3), + ToyAssimGrowthModel(), + ToyAssimModel(), + ToyCDemandModel(; optimal_biomass=2.0, development_duration=100.0), + ToyCAllocationModel(), + ToyCBiomassModel(1.2), + ToyLeafSurfaceModel(0.02), + ToyPlantLeafSurfaceModel(), + ToyLAIfromLeafAreaModel(1.0), + ToyLightPartitioningModel(), + ToyMaintenanceRespirationModel(2.0, 0.06, 25.0, 0.8, 0.02), + ToyPlantRmModel(), + ToySoilWaterModel(), + ToyEnvironmentReaderModel(), + ToyEnvironmentControllerModel(30.0, 22.0), + ToySelectiveCallControllerModel( + (28.0, 31.0), + 22.0; + selected_object=:leaf, + ), + ToyStockWriterModel(4.0), + ToyDevelopmentModel(0.5), + ToyDailyDevelopmentModel(2.0), + ) + expected_inputs = ( + (), + (:TT_cu,), + (:LAI,), + (:aPPFD,), + (:aPPFD,), + (:aPPFD, :soil_water_content), + (:TT,), + (:carbon_assimilation, :Rm, :carbon_demand), + (:carbon_allocation,), + (:carbon_biomass,), + (:leaf_surfaces,), + (:plant_surfaces,), + (:aPPFD_larger_scale, :total_surface, :surface), + (:carbon_biomass,), + (:Rm_organs,), + (), + (), + (), + (), + (), + (:TT, :stress), + (), + ) + expected_outputs = ( + (:TT, :TT_cu), + (:LAI,), + (:aPPFD,), + (:biomass, :biomass_increment), + (:carbon_assimilation, :Rm, :Rg, :biomass_increment, :biomass), + (:carbon_assimilation,), + (:carbon_demand,), + (:carbon_offer, :carbon_allocation), + (:carbon_biomass_increment, :carbon_biomass, :growth_respiration), + (:surface,), + (:surface,), + (:LAI, :total_surface), + (:aPPFD,), + (:Rm,), + (:Rm,), + (:soil_water_content,), + (:temperature_seen,), + (:trial_temperature_seen, :accepted_temperature_seen), + (:target_count, :trial_temperature_seen, :accepted_temperature_seen), + (:stock,), + (:growth,), + (:daily_growth,), + ) + expected_environment_inputs = ( + (:T,), + (), + (:Ri_PAR_f,), + (), + (), + (), + (), + (), + (), + (), + (), + (), + (), + (:T,), + (), + (), + (:T,), + (), + (), + (), + (), + (), + ) + expected_environment_outputs = ( + (), + (), + (), + (), + (), + (), + (), + (), + (), + (), + (), + (), + (), + (), + (), + (), + (), + (:T,), + (), + (), + (), + (), + ) + + for ( + model, + expected_input, + expected_output, + expected_environment_input, + expected_environment_output, + ) in zip( + models, + expected_inputs, + expected_outputs, + expected_environment_inputs, + expected_environment_outputs, + ) + @test Tuple(inputs(model)) == expected_input + @test Tuple(outputs(model)) == expected_output + @test Tuple(PlantSimEngine.environment_inputs(model)) == + expected_environment_input + @test Tuple(PlantSimEngine.environment_outputs(model)) == + expected_environment_output + end + + @test Tuple(PlantSimEngine.environment_inputs(Process2Model())) == (:T, :Wind, :Rh) +end + +@testset "Toy model generic numeric parameters" begin + degree_days = ToyDegreeDaysCumulModel(; + init_TT=Float32(2), + T_base=Float32(10), + T_max=Float32(43), ) + lai = ToyLAIModel(; + max_lai=Float32(8), + dd_incslope=Float32(800), + inc_slope=Float32(110), + dd_decslope=Float32(1500), + dec_slope=Float32(20), + ) + respiration = ToyMaintenanceRespirationModel( + Float32(2), + Float32(0.06), + Float32(25), + Float32(0.8), + Float32(0.02), + ) + development = ToyDevelopmentModel(Float32(0.5)) + daily_development = ToyDailyDevelopmentModel(Float32(2)) - mapping = ModelMapping( - ToyLAIModel(); - status=(TT_cu=cumsum(meteo_day.TT),), + @test degree_days isa ToyDegreeDaysCumulModel{Float32} + @test lai isa ToyLAIModel{Float32} + @test respiration isa ToyMaintenanceRespirationModel{Float32} + @test development isa ToyDevelopmentModel{Float32} + @test daily_development isa ToyDailyDevelopmentModel{Float32} + @test all(value -> value isa Float32, values(PlantSimEngine.outputs_(degree_days))) + @test all(value -> value isa Float32, values(PlantSimEngine.outputs_(lai))) + @test all(value -> value isa Float32, values(PlantSimEngine.outputs_(respiration))) + @test PlantSimEngine.outputs_(ToySoilWaterModel(Float32[0.25])).soil_water_content isa + Float32 +end + +@testset "Toy environment contracts compose with declared-only forcing" begin + forcing = ( + T=Float32(20), + Ri_PAR_f=Float32(300), + duration=Day(1), + ) + model = CompositeModel( + ToyDegreeDaysCumulModel(; + init_TT=Float32(0), + T_base=Float32(10), + T_max=Float32(43), + ), + ToyLAIModel( + max_lai=Float32(8), + dd_incslope=Float32(30), + inc_slope=Float32(10), + dd_decslope=Float32(200), + dec_slope=Float32(20), + ), + Beer(Float32(0.5)); + environment=forcing, + ) + + @test validate_environment_inputs(model) === nothing + @test_throws "Ri_PAR_f" validate_environment_inputs( + model, + (T=Float32(20), duration=Day(1)), ) - outputs = @test_nowarn run!(mapping) + simulation = run!(model; steps=3, outputs=:all) + status = only(model_objects(model)).status + @test status.TT_cu == Float32(30) + @test status.LAI isa Float32 + @test status.aPPFD isa Float32 + @test length(outputs(simulation)) == 4 - @test outputs[:TT_cu] == cumsum(meteo_day.TT) - @test outputs[:LAI][begin] ≈ 0.00554987593080316 - @test outputs[:LAI][end] ≈ 0.0 + respiration = ToyMaintenanceRespirationModel( + Float32(2), + Float32(0.06), + Float32(25), + Float32(0.8), + Float32(0.02), + ) + respiration_model = toy_scene( + Status(carbon_biomass=Float32(2)), + respiration; + environment=(T=Float32(20), duration=Day(1)), + ) + @test validate_environment_inputs(respiration_model) === nothing + run!(respiration_model) + @test only(model_objects(respiration_model)).status.Rm isa Float32 end -@testset "ToyLAIModel+Beer" begin - mapping = ModelMapping( +@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 + + rue = 0.3 + 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), - status=(TT_cu=cumsum(meteo_day.TT),) + ToyRUEGrowthModel(rue); + environment=meteo, ) + 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 - outputs = run!(mapping, meteo_day) +@testset "One-plant tutorial composition" begin + scalar_model = CompositeModel( + Object( + :plant; + scale=:Plant, + kind=:plant, + status=Status(aPPFD=120.0, surface=3.0), + ), + Object( + :leaf_1; + scale=:Leaf, + kind=:leaf, + parent=:plant, + status=Status(surface=1.0), + ), + Object( + :leaf_2; + scale=:Leaf, + kind=:leaf, + parent=:plant, + status=Status(surface=2.0), + ); + applications=( + ModelSpec( + ToyLightPartitioningModel(); + name=:leaf_light, + on=Many(scale=:Leaf), + inputs=( + :aPPFD_larger_scale => One( + scale=:Plant, + within=SelfPlant(), + var=:aPPFD, + ), + :total_surface => One( + scale=:Plant, + within=SelfPlant(), + var=:surface, + ), + ), + ), + ), + ) + scalar_simulation = run!(scalar_model) + scalar_states = final_state(scalar_simulation, Many(scale=:Leaf)) + @test scalar_states[:leaf_1].aPPFD == 40.0 + @test scalar_states[:leaf_2].aPPFD == 80.0 - @test mean(outputs[:aPPFD]) ≈ 9.511021781482347 - @test mean(outputs[:LAI]) ≈ 1.098492557536525 + computed_model = CompositeModel( + Object( + :plant; + scale=:Plant, + kind=:plant, + status=Status(aPPFD=120.0), + ), + Object( + :leaf_1; + scale=:Leaf, + kind=:leaf, + parent=:plant, + status=Status(carbon_biomass=50.0), + ), + Object( + :leaf_2; + scale=:Leaf, + kind=:leaf, + parent=:plant, + status=Status(carbon_biomass=100.0), + ); + applications=( + ModelSpec( + ToyLeafSurfaceModel(0.02); + name=:leaf_surface, + on=Many(scale=:Leaf), + ), + ModelSpec( + ToyPlantLeafSurfaceModel(); + name=:plant_surface, + on=One(scale=:Plant), + inputs=( + :leaf_surfaces => Many( + scale=:Leaf, + within=Subtree(), + application=:leaf_surface, + var=:surface, + ), + ), + ), + ModelSpec( + ToyLightPartitioningModel(); + name=:leaf_light, + on=Many(scale=:Leaf), + inputs=( + :aPPFD_larger_scale => One( + scale=:Plant, + within=SelfPlant(), + var=:aPPFD, + ), + :total_surface => One( + scale=:Plant, + within=SelfPlant(), + application=:plant_surface, + var=:surface, + ), + ), + ), + ), + ) + computed_simulation = run!(computed_model) + plant_state = final_state(computed_simulation, One(scale=:Plant)) + leaf_states = final_state(computed_simulation, Many(scale=:Leaf)) + @test plant_state.surface == 3.0 + @test leaf_states[:leaf_1].surface == 1.0 + @test leaf_states[:leaf_2].surface == 2.0 + @test leaf_states[:leaf_1].aPPFD == 40.0 + @test leaf_states[:leaf_2].aPPFD == 80.0 + @test only( + row for row in Diagnostics.explain_bindings(computed_model) + if row.application_id == :plant_surface + ).carrier_kind == :ref_vector end +@testset "Several-plant tutorial composition" begin + plant_template = CompositeModelTemplate(( + ModelSpec( + ToyLeafSurfaceModel(0.02); + name=:leaf_surface, + on=Many(scale=:Leaf), + ), + ModelSpec( + ToyPlantLeafSurfaceModel(); + name=:plant_surface, + on=One(scale=:Plant), + inputs=( + :leaf_surfaces => Many( + scale=:Leaf, + within=Subtree(), + application=:leaf_surface, + var=:surface, + ), + ), + ), + ModelSpec( + ToyLightPartitioningModel(); + name=:leaf_light, + on=Many(scale=:Leaf), + inputs=( + :aPPFD_larger_scale => One( + scale=:Plant, + within=SelfPlant(), + var=:aPPFD, + ), + :total_surface => One( + scale=:Plant, + within=SelfPlant(), + application=:plant_surface, + var=:surface, + ), + ), + ), + )) -@testset "ToyRUEGrowthModel" begin - rue = 0.3 - @test_nowarn ModelMapping(ToyRUEGrowthModel(rue)) - @test_nowarn ModelMapping(ToyRUEGrowthModel(rue); status=(aPPFD=[10.0, 30.0, 25.0],)) + plant_a = ObjectInstance( + :plant_a, + plant_template; + root=Object( + :plant_a_root; + scale=:Plant, + kind=:plant, + status=Status(aPPFD=120.0), + ), + objects=( + Object( + :plant_a_leaf_1; + scale=:Leaf, + kind=:leaf, + parent=:plant_a_root, + status=Status(carbon_biomass=50.0), + ), + Object( + :plant_a_leaf_2; + scale=:Leaf, + kind=:leaf, + parent=:plant_a_root, + status=Status(carbon_biomass=100.0), + ), + ), + ) + plant_b = ObjectInstance( + :plant_b, + plant_template; + root=Object( + :plant_b_root; + scale=:Plant, + kind=:plant, + status=Status(aPPFD=200.0), + ), + objects=( + Object( + :plant_b_leaf_1; + scale=:Leaf, + kind=:leaf, + parent=:plant_b_root, + status=Status(carbon_biomass=50.0), + ), + Object( + :plant_b_leaf_2; + scale=:Leaf, + kind=:leaf, + parent=:plant_b_root, + status=Status(carbon_biomass=50.0), + ), + ), + ) - # One time step: - mapping = ModelMapping(ToyRUEGrowthModel(rue); status=(aPPFD=30.0,)) + model = CompositeModel(plant_a, plant_b) + simulation = run!(model) + plant_states = final_state(simulation, Many(scale=:Plant)) + leaf_states = final_state(simulation, Many(scale=:Leaf)) + @test plant_states[:plant_a_root].surface == 3.0 + @test plant_states[:plant_b_root].surface == 2.0 + @test sum( + leaf_states[id].aPPFD + for id in (:plant_a_leaf_1, :plant_a_leaf_2) + ) == 120.0 + @test sum( + leaf_states[id].aPPFD + for id in (:plant_b_leaf_1, :plant_b_leaf_2) + ) == 200.0 + @test Set(row.name for row in Diagnostics.explain_instances(model)) == + Set((:plant_a, :plant_b)) - outputs = run!(mapping, executor=SequentialEx()) - @test outputs[:biomass][1] ≈ rue * 30.0 + plant_c = ObjectInstance( + :plant_c, + plant_template; + root=Object( + :plant_c_root; + scale=:Plant, + kind=:plant, + status=Status(aPPFD=120.0), + ), + objects=( + Object( + :plant_c_leaf_1; + scale=:Leaf, + kind=:leaf, + parent=:plant_c_root, + status=Status(carbon_biomass=50.0), + ), + Object( + :plant_c_leaf_2; + scale=:Leaf, + kind=:leaf, + parent=:plant_c_root, + status=Status(carbon_biomass=100.0), + ), + ), + overrides=(leaf_surface=ToyLeafSurfaceModel(0.04),), + ) + override_simulation = run!(CompositeModel(plant_c)) + @test final_state(override_simulation, One(scale=:Plant)).surface == 6.0 +end - # Several time steps: - aPPFD = [10.0, 30.0, 25.0] - mapping = ModelMapping(ToyRUEGrowthModel(rue); status=(aPPFD=aPPFD,)) +@testset "Environment tutorial composition" begin + forcing = ( + air_temperature=20.0, + incident_par=300.0, + duration=Day(1), + ) + global_model = CompositeModel( + Object(:plant; scale=:Plant, kind=:plant); + applications=( + ModelSpec( + ToyDegreeDaysCumulModel(); + name=:degree_days, + on=One(scale=:Plant), + environment=Environment( + provider=:global, + sources=(T=:air_temperature,), + ), + ), + ModelSpec( + ToyLAIModel(); + name=:lai, + on=One(scale=:Plant), + ), + ModelSpec( + Beer(0.6); + name=:light, + on=One(scale=:Plant), + environment=Environment( + provider=:global, + sources=(Ri_PAR_f=:incident_par,), + ), + ), + ), + environment=forcing, + ) + @test validate_environment_inputs(global_model) === nothing + global_state = final_state(run!(global_model)) + @test global_state.TT_cu == 10.0 + global_environment_rows = + Diagnostics.explain_environment_bindings(global_model) + @test only( + row for row in global_environment_rows + if row.application_id == :degree_days + ).source_inputs == [:air_temperature] + @test only( + row for row in global_environment_rows + if row.application_id == :light + ).source_inputs == [:incident_par] - outputs = run!(mapping, executor=SequentialEx()) - @test outputs[:biomass] ≈ cumsum(rue * aPPFD) + backend = ToySpatialEnvironment( + Dict( + :sun => (Ri_PAR_f=400.0,), + :shade => (Ri_PAR_f=100.0,), + ); + step_seconds=3600.0, + ) + spatial_model = CompositeModel( + Object( + :sun_leaf; + scale=:Leaf, + kind=:leaf, + geometry=(cell=:sun,), + status=Status(LAI=2.0), + ), + Object( + :shade_leaf; + scale=:Leaf, + kind=:leaf, + geometry=(cell=:shade,), + status=Status(LAI=2.0), + ); + applications=( + ModelSpec( + Beer(0.6); + name=:light, + on=Many(scale=:Leaf), + environment=Environment(backend=backend), + ), + ), + ) + spatial_states = final_state(run!(spatial_model), Many(scale=:Leaf)) + @test spatial_states[:sun_leaf].aPPFD > + spatial_states[:shade_leaf].aPPFD + handles = Dict( + row.object_id => row.handle.cell + for row in Diagnostics.explain_environment_bindings(spatial_model) + ) + @test handles == Dict(:sun_leaf => :sun, :shade_leaf => :shade) 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 +@testset "Cadence tutorial composition" begin + model = CompositeModel( + Object(:plant; scale=:Plant, kind=:plant); + applications=( + ModelSpec( + ToyDegreeDaysCumulModel(); + name=:degree_days, + on=One(scale=:Plant), + every=Day(1), + ), + ModelSpec( + ToyLAIModel(); + name=:lai, + on=One(scale=:Plant), + every=Day(1), + ), + ModelSpec( + Beer(0.6); + name=:light, + on=One(scale=:Plant), + inputs=( + :LAI => One( + within=Self(), + application=:lai, + var=:LAI, + policy=HoldLast(), + window=Day(1), + ), + ), + every=Hour(1), + ), + ), + environment=[ + (T=20.0, Ri_PAR_f=300.0, duration=Hour(1)) + for _ in 1:25 + ], + ) + simulation = run!(model; steps=25, outputs=:all) + schedule = Dict( + row.application_id => row + for row in Diagnostics.explain_schedule(model) + ) + @test schedule[:degree_days].dt_steps == 24.0 + @test schedule[:lai].dt_steps == 24.0 + @test schedule[:light].dt_steps == 1.0 + hold_binding = only( + row for row in Diagnostics.explain_bindings(model) + if row.application_id == :light + ) + @test hold_binding.policy isa HoldLast + @test hold_binding.carrier_kind == :temporal_stream + summaries = Dict( + (row.application_id, row.variable) => row.nsamples + for row in Diagnostics.explain_outputs(simulation) + ) + @test summaries[(:degree_days, :TT_cu)] == 2 + @test summaries[(:lai, :LAI)] == 2 + @test summaries[(:light, :aPPFD)] == 25 +end - # One time step: - mapping = ModelMapping(ToyAssimGrowthModel(); status=(aPPFD=30.0,)) +@testset "Structure-change tutorial composition" begin + model = CompositeModel( + Object(:plant; scale=:Plant, kind=:plant), + Object(:branch; scale=:Axis, kind=:axis, parent=:plant), + Object( + :leaf_1; + scale=:Leaf, + kind=:leaf, + parent=:plant, + status=Status(TT=10.0), + ), + Object( + :leaf_2; + scale=:Leaf, + kind=:leaf, + parent=:plant, + status=Status(TT=15.0), + ); + applications=( + ModelSpec( + ToyCDemandModel( + optimal_biomass=12.0, + development_duration=120.0, + ); + name=:carbon_demand, + on=Many(scale=:Leaf), + ), + ModelSpec( + ToyCBiomassModel(1.2); + name=:biomass, + on=Many(scale=:Leaf), + inputs=( + :carbon_allocation => One( + within=Self(), + application=:carbon_demand, + var=:carbon_demand, + ), + ), + ), + ), + ) + simulation = run!(model; outputs=:all) + initial_targets = only( + row for row in Diagnostics.explain_applications(simulation) + if row.application_id == :biomass + ).target_ids + @test initial_targets == [:leaf_1, :leaf_2] - @test isempty(to_initialize(mapping)) + register_object!( + model, + Object( + :leaf_3; + scale=:Leaf, + kind=:leaf, + status=Status(TT=20.0), + ); + parent=:plant, + ) + @test only( + row for row in Diagnostics.explain_applications(simulation) + if row.application_id == :biomass + ).target_ids == initial_targets + continue!(simulation) + @test only( + row for row in Diagnostics.explain_applications(simulation) + if row.application_id == :biomass + ).target_ids == [:leaf_1, :leaf_2, :leaf_3] + @test only( + object.parent for object in model_objects(model) + if object.id == ObjectId(:leaf_3) + ) == ObjectId(:plant) - outputs = run!(mapping) - @test outputs[:biomass] ≈ [4.5] + reparent_object!(model, :leaf_3, :branch) + continue!(simulation) + @test only( + object.parent for object in model_objects(model) + if object.id == ObjectId(:leaf_3) + ) == ObjectId(:branch) - # Several time steps: - mapping = ModelMapping(ToyAssimGrowthModel(); status=(aPPFD=[10.0, 30.0, 25.0],)) + @test remove_object!(model, :leaf_2).id == ObjectId(:leaf_2) + continue!(simulation) + @test Set(object_ids(model; scale=:Leaf)) == + Set(ObjectId.((:leaf_1, :leaf_3))) + @test only( + row for row in Diagnostics.explain_applications(simulation) + if row.application_id == :biomass + ).target_ids == [:leaf_1, :leaf_3] - outputs = run!(mapping) - @test outputs[:biomass] ≈ cumsum(outputs[:biomass_increment]) - @test outputs[:biomass_increment] ≈ [0.8333333333333334, 4.5, 3.5833333333333335] + rows = collect_outputs(simulation; sink=nothing) + demand = Dict( + (row.timestep, row.object_id) => row.value + for row in rows + if row.application_id == :carbon_demand && + row.variable == :carbon_demand + ) + increment = Dict( + (row.timestep, row.object_id) => row.value + for row in rows + if row.application_id == :biomass && + row.variable == :carbon_biomass_increment + ) + respiration = Dict( + (row.timestep, row.object_id) => row.value + for row in rows + if row.application_id == :biomass && + row.variable == :growth_respiration + ) + @test all( + demand[key] ≈ increment[key] + respiration[key] + for key in keys(demand) + ) + @test Dict( + id => length(collect_outputs( + simulation, + id, + :carbon_biomass; + sink=nothing, + )) + for id in (:leaf_1, :leaf_2, :leaf_3) + ) == Dict(:leaf_1 => 4, :leaf_2 => 3, :leaf_3 => 3) end -@testset "ToyLAIModel+Beer+ToyRUEGrowthModel" begin - rue = 0.3 - mapping = ModelMapping( - ToyLAIModel(), - Beer(0.5), - ToyRUEGrowthModel(rue), - status=(TT_cu=cumsum(meteo_day.TT),), +@testset "Mutable-environment tutorial composition" begin + environment = ToySpatialEnvironment( + Dict(:canopy => (T=20.0,)); + step_seconds=3600.0, + ) + model = CompositeModel( + Object( + :leaf; + scale=:Leaf, + kind=:leaf, + geometry=(cell=:canopy,), + ); + applications=( + ModelSpec( + ToyEnvironmentReaderModel(); + name=:reader, + on=One(scale=:Leaf), + environment=Environment(backend=environment), + ), + ModelSpec( + ToyEnvironmentControllerModel(30.0, 22.0); + name=:controller, + on=One(scale=:Leaf), + calls=( + :reader => One( + scale=:Leaf, + application=:reader, + ), + ), + environment=Environment( + backend=environment, + sink=:cells, + ), + ), + ), + ) + simulation = run!(model; outputs=:all) + state = final_state(simulation) + @test state.trial_temperature_seen == 30.0 + @test state.accepted_temperature_seen == 22.0 + @test environment.cells[:canopy].T == 22.0 + @test only( + row for row in Diagnostics.explain_outputs(simulation) + if row.application_id == :reader + ).nsamples == 1 + + spatial_environment = ToySpatialEnvironment( + Dict( + :sun => (T=26.0,), + :shade => (T=18.0,), + ); + step_seconds=3600.0, + ) + spatial_model = CompositeModel( + Object( + :sun_leaf; + scale=:Leaf, + geometry=(cell=:sun,), + ), + Object( + :shade_leaf; + scale=:Leaf, + geometry=(cell=:shade,), + ); + applications=( + ModelSpec( + ToyEnvironmentReaderModel(); + name=:temperature, + on=Many(scale=:Leaf), + environment=Environment(backend=spatial_environment), + ), + ), + ) + spatial_states = final_state( + run!(spatial_model), + Many(scale=:Leaf), ) + @test spatial_states[:sun_leaf].temperature_seen == 26.0 + @test spatial_states[:shade_leaf].temperature_seen == 18.0 + @test Dict( + row.object_id => row.handle.cell + for row in Diagnostics.explain_environment_bindings(spatial_model) + ) == Dict(:sun_leaf => :sun, :shade_leaf => :shade) +end + +@testset "Advanced execution-control tutorial composition" begin + controller = ToySelectiveCallControllerModel( + (28, 31.0f0), + 22; + selected_object=:sun_leaf, + ) + @test PlantSimEngine.inputs_(controller) == NamedTuple() + @test PlantSimEngine.outputs_(controller) == ( + target_count=0, + trial_temperature_seen=0.0, + accepted_temperature_seen=0.0, + ) + + environment = ToySpatialEnvironment( + Dict( + :sun => (T=26.0,), + :shade => (T=18.0,), + ); + step_seconds=3600.0, + ) + model = CompositeModel( + Object(:plant; scale=:Plant, kind=:plant), + Object( + :sun_leaf; + scale=:Leaf, + kind=:leaf, + parent=:plant, + geometry=(cell=:sun,), + ), + Object( + :shade_leaf; + scale=:Leaf, + kind=:leaf, + parent=:plant, + geometry=(cell=:shade,), + ); + applications=( + ModelSpec( + ToyEnvironmentReaderModel(); + name=:reader, + on=Many(scale=:Leaf), + environment=Environment(backend=environment), + ), + ModelSpec( + controller; + name=:controller, + on=One(scale=:Plant), + calls=( + :readers => Many( + scale=:Leaf, + within=Subtree(), + application=:reader, + ), + ), + ), + ), + ) + simulation = run!(model; outputs=:all) + plant_state = final_state(simulation, :plant) + leaf_states = final_state(simulation, Many(scale=:Leaf)) + @test plant_state.target_count == 2 + @test plant_state.trial_temperature_seen == 31.0 + @test plant_state.accepted_temperature_seen == 22.0 + @test leaf_states[:sun_leaf].temperature_seen == 22.0 + @test leaf_states[:shade_leaf].temperature_seen == 0.0 + @test Dict( + row.object_id => row.nsamples + for row in Diagnostics.explain_outputs(simulation) + if row.application_id == :reader + ) == Dict(:sun_leaf => 1, :shade_leaf => 0) - # 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) + writer = ToyStockWriterModel(4) + @test PlantSimEngine.inputs_(writer) == NamedTuple() + @test PlantSimEngine.outputs_(writer) == (stock=0.0,) - # If we provide a serial executor, it works without a warning: - outputs = @test_nowarn run!(mapping, meteo_day, executor=SequentialEx()) + @test_throws "Ambiguous canonical writers" Advanced.refresh_bindings!( + CompositeModel( + Object(:reserve; scale=:Organ); + applications=( + ModelSpec( + ToyStockWriterModel(4); + name=:initial_stock, + on=One(scale=:Organ), + ), + ModelSpec( + ToyStockWriterModel(8); + name=:adjusted_stock, + on=One(scale=:Organ), + ), + ), + ), + ) + + writer_model = CompositeModel( + Object(:reserve; scale=:Organ); + applications=( + ModelSpec( + ToyStockWriterModel(4); + name=:initial_stock, + on=One(scale=:Organ), + ), + ModelSpec( + ToyStockWriterModel(8); + name=:adjusted_stock, + on=One(scale=:Organ), + updates=Updates(:stock; after=:initial_stock), + ), + ModelSpec( + ToyStockWriterModel(99); + name=:alternative_stock, + on=One(scale=:Organ), + output_routing=(stock=:stream_only,), + ), + ), + ) + writer_simulation = run!(writer_model; outputs=:all) + @test final_state(writer_simulation).stock == 8.0 + @test Dict( + row.application_id => row.value + for row in collect_outputs( + writer_simulation, + :reserve, + :stock; + sink=nothing, + ) + ) == Dict( + :initial_stock => 4.0, + :adjusted_stock => 8.0, + :alternative_stock => 99.0, + ) + stock_writer = only( + row for row in Diagnostics.explain_writers(writer_model) + if row.variable == :stock + ) + @test stock_writer.application_ids == + [:initial_stock, :adjusted_stock] +end - @test mean(outputs[:aPPFD]) ≈ 9.511021781482347 - @test mean(outputs[:LAI]) ≈ 1.098492557536525 - @test outputs[:biomass][end] ≈ 1041.4687939085675 rtol = 1e-4 +@testset "Model-developer journey composition" begin + development = ToyDevelopmentModel(0.5) + @test PlantSimEngine.inputs_(development) == ( + TT=Required(Real), + stress=Default(1.0), + ) + @test PlantSimEngine.outputs_(development) == (growth=0.0,) + direct_status = Status(TT=8.0, stress=0.75, growth=0.0) + PlantSimEngine.run!( + development, + direct_status, + nothing, + nothing, + nothing, + ) + @test direct_status.growth == 3.0 + + one_object = CompositeModel( + Object(:leaf; scale=:Leaf); + applications=( + ModelSpec( + ToyDegreeDaysCumulModel(T_base=10.0); + name=:thermal_time, + on=One(scale=:Leaf), + ), + ModelSpec( + development; + name=:development, + on=One(scale=:Leaf), + ), + ), + environment=Atmosphere( + T=18.0, + Wind=1.0, + Rh=0.7, + duration=Dates.Day(1), + ), + ) + one_simulation = run!(one_object) + @test final_state(one_simulation).TT == 8.0 + @test final_state(one_simulation).stress == 1.0 + @test final_state(one_simulation).growth == 4.0 + + several_objects = CompositeModel( + Object(:leaf_1; scale=:Leaf), + Object(:leaf_2; scale=:Leaf); + applications=( + ModelSpec( + ToyDegreeDaysCumulModel(T_base=10.0); + name=:thermal_time, + on=Many(scale=:Leaf), + ), + ModelSpec( + development; + name=:development, + on=Many(scale=:Leaf), + ), + ), + environment=Atmosphere( + T=18.0, + Wind=1.0, + Rh=0.7, + duration=Dates.Day(1), + ), + ) + several_states = final_state( + run!(several_objects), + Many(scale=:Leaf), + ) + @test getproperty.(values(several_states), :growth) == + fill(4.0, 2) + + cross_object = CompositeModel( + Object(:soil; scale=:Soil, status=Status(stress=0.4)), + Object(:leaf; scale=:Leaf, status=Status(TT=10.0)); + applications=( + ModelSpec( + development; + name=:development, + on=One(scale=:Leaf), + inputs=( + :stress => One( + scale=:Soil, + within=SceneScope(), + var=:stress, + from_status=true, + ), + ), + ), + ), + ) + cross_simulation = run!(cross_object) + @test final_state(cross_simulation, :leaf).growth == 2.0 + stress_binding = only( + row for row in Diagnostics.explain_bindings(cross_object) + if row.input == :stress + ) + @test stress_binding.carrier_kind == :ref + @test stress_binding.source_ids == [:soil] + + respiration = ToyMaintenanceRespirationModel( + 2.0, + 0.06, + 25.0, + 0.5, + 0.02, + ) + multiscale = CompositeModel( + Object(:plant; scale=:Plant), + Object( + :leaf_1; + scale=:Leaf, + parent=:plant, + status=Status(carbon_biomass=10.0), + ), + Object( + :leaf_2; + scale=:Leaf, + parent=:plant, + status=Status(carbon_biomass=20.0), + ); + applications=( + ModelSpec( + respiration; + name=:maintenance, + on=Many(scale=:Leaf), + ), + ModelSpec( + ToyPlantRmModel(); + name=:plant_maintenance, + on=One(scale=:Plant), + inputs=( + :Rm_organs => Many( + scale=:Leaf, + within=Subtree(), + application=:maintenance, + var=:Rm, + ), + ), + ), + ), + environment=Atmosphere( + T=25.0, + Wind=1.0, + Rh=0.7, + duration=Dates.Hour(1), + ), + ) + multiscale_simulation = run!(multiscale) + leaf_respiration = getproperty.( + values(final_state( + multiscale_simulation, + Many(scale=:Leaf), + )), + :Rm, + ) + @test final_state(multiscale_simulation, :plant).Rm ≈ + sum(leaf_respiration) + respiration_binding = only( + row for row in Diagnostics.explain_bindings(multiscale) + if row.application_id == :plant_maintenance + ) + @test respiration_binding.carrier_kind == :ref_vector + @test PlantSimEngine.environment_inputs_(respiration) == (T=0.0,) + + daily = ToyDailyDevelopmentModel(2.0) + @test PlantSimEngine.timespec(daily) == + PlantSimEngine.ClockSpec(24.0, 1.0) + @test PlantSimEngine.output_policy(daily) == + (daily_growth=HoldLast(),) + daily_model = CompositeModel( + Object(:plant; scale=:Plant); + applications=( + ModelSpec( + daily; + name=:daily_development, + on=One(scale=:Plant), + ), + ), + environment=[ + (duration=Dates.Hour(1),) + for _ in 1:25 + ], + ) + daily_simulation = + run!(daily_model; steps=25, outputs=:all) + @test final_state(daily_simulation).daily_growth == 4.0 + @test only( + row for row in Diagnostics.explain_outputs(daily_simulation) + if row.variable == :daily_growth + ).nsamples == 2 + + hard_call_environment = ToySpatialEnvironment( + Dict( + :sun => (T=26.0,), + :shade => (T=18.0,), + ); + step_seconds=3600.0, + ) + hard_call_model = CompositeModel( + Object(:plant; scale=:Plant), + Object( + :sun_leaf; + scale=:Leaf, + parent=:plant, + geometry=(cell=:sun,), + ), + Object( + :shade_leaf; + scale=:Leaf, + parent=:plant, + geometry=(cell=:shade,), + ); + applications=( + ModelSpec( + ToyEnvironmentReaderModel(); + name=:reader, + on=Many(scale=:Leaf), + environment=Environment(backend=hard_call_environment), + ), + ModelSpec( + ToySelectiveCallControllerModel( + (28.0, 31.0), + 22.0; + selected_object=:sun_leaf, + ); + name=:controller, + on=One(scale=:Plant), + ), + ), + ) + hard_call_row = only( + row for row in Diagnostics.explain_calls(hard_call_model) + if row.application_id == :controller + ) + @test hard_call_row.origin == :model_default + @test hard_call_row.callee_object_ids == + [:shade_leaf, :sun_leaf] + hard_call_simulation = run!(hard_call_model; outputs=:all) + @test final_state( + hard_call_simulation, + :plant, + ).accepted_temperature_seen == 22.0 + + mutable_environment = ToySpatialEnvironment( + Dict(:canopy => (T=20.0,)); + step_seconds=3600.0, + ) + mutable_model = CompositeModel( + Object( + :leaf; + scale=:Leaf, + geometry=(cell=:canopy,), + ); + applications=( + ModelSpec( + ToyEnvironmentReaderModel(); + name=:reader, + on=One(scale=:Leaf), + environment=Environment(backend=mutable_environment), + ), + ModelSpec( + ToyEnvironmentControllerModel(30.0, 22.0); + name=:controller, + on=One(scale=:Leaf), + environment=Environment( + backend=mutable_environment, + sink=:cells, + ), + ), + ), + ) + mutable_call = only( + row for row in Diagnostics.explain_calls(mutable_model) + if row.application_id == :controller + ) + @test mutable_call.origin == :model_default + mutable_simulation = run!(mutable_model; outputs=:all) + @test mutable_environment.cells[:canopy].T == 22.0 + @test final_state( + mutable_simulation, + ).accepted_temperature_seen == 22.0 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..19698601f --- /dev/null +++ b/test/test-unified-model-object-api.jl @@ -0,0 +1,4181 @@ +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=Required(Vector{Float64}),) +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=Required(Vector{Float64}),) +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=Required(Vector{Float64}), leaf_tokens=Required(Vector{Any})) +PlantSimEngine.outputs_(::ModelObjectCarrierConsumerModel) = (carrier_total=0.0,) + +function PlantSimEngine.run!(::ModelObjectCarrierConsumerModel, status, environment, constants=nothing, context=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.environment_inputs_(::ModelObjectEnvironmentProbeModel) = (T=0.0, CO2=0.0) + +function PlantSimEngine.run!(::ModelObjectEnvironmentProbeModel, status, environment, constants=nothing, context=nothing) + status.temperature_seen = environment.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.environment_inputs_(::ModelObjectEnvironmentCO2ProbeModel) = + (T=0.0, CO2=0.0) + +function PlantSimEngine.run!( + ::ModelObjectEnvironmentCO2ProbeModel, + status, + environment, + constants=nothing, + context=nothing, +) + status.temperature_seen = environment.T + status.co2_seen = environment.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.environment_inputs_(::ModelObjectEnvironmentCO2HintProbeModel) = + (T=0.0, CO2=0.0) +PlantSimEngine.environment_hint(::Type{<:ModelObjectEnvironmentCO2HintProbeModel}) = + (bindings=(CO2=(source=:Ca, reducer=MeanReducer()),),) + +function PlantSimEngine.run!( + ::ModelObjectEnvironmentCO2HintProbeModel, + status, + environment, + constants=nothing, + context=nothing, +) + status.temperature_seen = environment.T + status.co2_seen = environment.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.environment_inputs_(::ModelObjectAggregatedEnvironmentProbeModel) = + (T=0.0, CO2=0.0) +PlantSimEngine.environment_hint(::Type{<:ModelObjectAggregatedEnvironmentProbeModel}) = ( + bindings=( + T=(source=:T, reducer=MaxReducer()), + CO2=(source=:Ca, reducer=MeanReducer()), + ), +) + +function PlantSimEngine.run!( + ::ModelObjectAggregatedEnvironmentProbeModel, + status, + environment, + constants=nothing, + context=nothing, +) + status.temperature_seen = environment.T + status.co2_seen = environment.CO2 + return nothing +end + +struct ModelObjectTemperatureOnlyProbeModel <: + AbstractModel_Object_Environment_ProbeModel end + +PlantSimEngine.inputs_(::ModelObjectTemperatureOnlyProbeModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectTemperatureOnlyProbeModel) = + (temperature_seen=0.0,) +PlantSimEngine.environment_inputs_(::ModelObjectTemperatureOnlyProbeModel) = (T=0.0,) + +function PlantSimEngine.run!( + ::ModelObjectTemperatureOnlyProbeModel, + status, + environment, + constants=nothing, + context=nothing, +) + status.temperature_seen = environment.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.environment_inputs_(::ModelObjectEnvironmentUpdateModel) = (T=0.0,) +PlantSimEngine.environment_outputs_(::ModelObjectEnvironmentUpdateModel) = (T=0.0,) + +function PlantSimEngine.run!(::ModelObjectEnvironmentUpdateModel, status, environment, constants=nothing, context=nothing) + status.temperature_update = environment.T + 1.0 + commit_environment!(context, (T=status.temperature_update, CO2=410.0)) + return nothing +end + +PlantSimEngine.@process "model_object_environment_update_caller" verbose = false + +struct ModelObjectEnvironmentUpdateCallerModel <: AbstractModel_Object_Environment_Update_CallerModel end + +PlantSimEngine.inputs_(::ModelObjectEnvironmentUpdateCallerModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectEnvironmentUpdateCallerModel) = (called_temperature=0.0,) + +function PlantSimEngine.run!(::ModelObjectEnvironmentUpdateCallerModel, status, environment, constants=nothing, context=nothing) + target = only(run_call!(context, :updater; publish=false)) + status.called_temperature = target.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, status, environment, constants=nothing, context=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, status, environment, constants=nothing, context=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, status, environment, constants=nothing, context=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, status, environment, constants=nothing, context=nothing) + status.signal = convert(typeof(model.prototype), context.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, status, environment, constants=nothing, context=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, status, environment, constants=nothing, context=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, + status, + environment, + constants=nothing, + context=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, + status, + environment, + constants=nothing, + context=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, status, environment, constants=nothing, context=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=Default([0.0]),) +PlantSimEngine.outputs_(::ModelObjectPlantSignalSumModel) = (signal_total=0.0,) + +function PlantSimEngine.run!(::ModelObjectPlantSignalSumModel, status, environment, constants=nothing, context=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, + status, + environment, + constants=nothing, + context=nothing, +) + return nothing +end + +function PlantSimEngine.run!(::ModelObjectSignalCallerModel, status, environment, constants=nothing, context=nothing) + target = only(run_call!(context, :signal; publish=true)) + status.called_signal = target.status.signal + return nothing +end + +PlantSimEngine.@process "model_object_environment_call_source" verbose = false + +struct ModelObjectEnvironmentCallSourceModel <: AbstractModel_Object_Environment_Call_SourceModel end + +PlantSimEngine.inputs_(::ModelObjectEnvironmentCallSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectEnvironmentCallSourceModel) = (temperature_seen=0.0,) +PlantSimEngine.environment_inputs_(::ModelObjectEnvironmentCallSourceModel) = (T=0.0,) + +function PlantSimEngine.run!(::ModelObjectEnvironmentCallSourceModel, status, environment, constants=nothing, context=nothing) + status.temperature_seen = environment.T + return nothing +end + +PlantSimEngine.@process "model_object_environment_call_controller" verbose = false + +struct ModelObjectEnvironmentCallControllerModel{T} <: AbstractModel_Object_Environment_Call_ControllerModel + local_temperature::T + publish::Bool +end + +PlantSimEngine.inputs_(::ModelObjectEnvironmentCallControllerModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectEnvironmentCallControllerModel) = (called_temperature=0.0,) +PlantSimEngine.environment_outputs_(::ModelObjectEnvironmentCallControllerModel) = (T=0.0,) + +function PlantSimEngine.run!(m::ModelObjectEnvironmentCallControllerModel, status, environment, constants=nothing, context=nothing) + local_environment = (T=m.local_temperature, CO2=410.0) + if m.publish + commit_environment!(context, local_environment) + target = only(run_call!(context, :source; publish=true)) + else + target = only(run_call!( + context, + :source; + environment=local_environment, + publish=false, + )) + end + status.called_temperature = target.status.temperature_seen + return nothing +end + +PlantSimEngine.@process "model_object_iterative_environment_call_controller" verbose = false + +struct ModelObjectIterativeEnvironmentCallControllerModel{T} <: + AbstractModel_Object_Iterative_Environment_Call_ControllerModel + trial_temperatures::NTuple{2,T} + accepted_temperature::T +end + +PlantSimEngine.inputs_(::ModelObjectIterativeEnvironmentCallControllerModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectIterativeEnvironmentCallControllerModel) = + (called_temperature=0.0,) +PlantSimEngine.environment_outputs_(::ModelObjectIterativeEnvironmentCallControllerModel) = + (T=0.0,) + +function PlantSimEngine.run!( + m::ModelObjectIterativeEnvironmentCallControllerModel, + status, + environment, + constants=nothing, + context=nothing, +) + target = only(call_targets(context, :source)) + for temperature in m.trial_temperatures + run_call!( + context, + :source; + environment=(T=temperature, CO2=410.0), + publish=false, + ) + end + commit_environment!(context, (T=m.accepted_temperature, CO2=410.0)) + run_call!(target; publish=true) + status.called_temperature = target.status.temperature_seen + return nothing +end + +PlantSimEngine.@process "model_object_spatial_environment_call_controller" verbose = false + +struct ModelObjectSpatialEnvironmentCallControllerModel{E} <: + AbstractModel_Object_Spatial_Environment_Call_ControllerModel + environment::E +end + +PlantSimEngine.inputs_(::ModelObjectSpatialEnvironmentCallControllerModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectSpatialEnvironmentCallControllerModel) = + (called_targets=0,) + +function PlantSimEngine.run!( + m::ModelObjectSpatialEnvironmentCallControllerModel, + status, + environment, + constants=nothing, + context=nothing, +) + targets = run_call!( + context, + :source; + environment=m.environment, + publish=false, + ) + status.called_targets = length(targets) + return nothing +end + +PlantSimEngine.@process "model_object_signal_consumer" verbose = false + +struct ModelObjectSignalConsumerModel <: AbstractModel_Object_Signal_ConsumerModel end + +PlantSimEngine.inputs_(::ModelObjectSignalConsumerModel) = (signal=Required(Float64),) +PlantSimEngine.outputs_(::ModelObjectSignalConsumerModel) = (observed_signal=0.0,) + +function PlantSimEngine.run!(::ModelObjectSignalConsumerModel, status, environment, constants=nothing, context=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=Required(Float64),) +PlantSimEngine.outputs_(::ModelObjectRenamedSignalConsumerModel) = + (observed_renamed_signal=0.0,) + +function PlantSimEngine.run!( + ::ModelObjectRenamedSignalConsumerModel, + status, + environment, + constants=nothing, + context=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=Default(7.0),) +PlantSimEngine.outputs_(::ModelObjectOptionalInputConsumerModel) = + (observed_optional_signal=0.0,) + +function PlantSimEngine.run!( + ::ModelObjectOptionalInputConsumerModel, + status, + environment, + constants=nothing, + context=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, + status, + environment, + constants=nothing, + context=nothing, +) + status.optional_call_count = + length(run_call!(context, :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=Required(Float64),) +PlantSimEngine.outputs_(::ModelObjectCycleAModel) = (cycle_a=0.0,) + +function PlantSimEngine.run!(::ModelObjectCycleAModel, status, environment, constants=nothing, context=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=Required(Float64),) +PlantSimEngine.outputs_(::ModelObjectCycleBModel) = (cycle_b=0.0,) + +function PlantSimEngine.run!(::ModelObjectCycleBModel, status, environment, constants=nothing, context=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=Required(Float64),) +PlantSimEngine.outputs_(::ModelObjectTemporalSumModel) = (temporal_total=0.0,) + +function PlantSimEngine.run!(::ModelObjectTemporalSumModel, status, environment, constants=nothing, context=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, status, environment, constants=nothing, context=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, status, environment, constants=nothing, context=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, status, environment, constants=nothing, context=nothing) + model = runtime_model(context) + if isapprox(context.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_first_growth" verbose = false + +struct ModelObjectFirstGrowthModel <: AbstractModel_Object_First_GrowthModel end + +PlantSimEngine.inputs_(::ModelObjectFirstGrowthModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectFirstGrowthModel) = NamedTuple() + +function PlantSimEngine.run!( + ::ModelObjectFirstGrowthModel, + status, + environment, + constants=nothing, + context=nothing, +) + model = runtime_model(context) + ObjectId(:first_growth_leaf) in object_ids(model) || + register_object!( + model, + Object( + :first_growth_leaf; + scale=:Leaf, + parent=:scene, + status=Status(signal=0.0), + ), + ) + return nothing +end + +PlantSimEngine.@process "model_object_execution_counter" verbose = false + +struct ModelObjectExecutionCounterModel <: + AbstractModel_Object_Execution_CounterModel end + +PlantSimEngine.inputs_(::ModelObjectExecutionCounterModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectExecutionCounterModel) = + (execution_count=0,) + +function PlantSimEngine.run!( + ::ModelObjectExecutionCounterModel, + status, + environment, + constants=nothing, + context=nothing, +) + status.execution_count += 1 + return nothing +end + +PlantSimEngine.@process "model_object_initializing_growth" verbose = false + +struct ModelObjectInitializingGrowthModel <: + AbstractModel_Object_Initializing_GrowthModel end + +PlantSimEngine.inputs_(::ModelObjectInitializingGrowthModel) = NamedTuple() +PlantSimEngine.outputs_(::ModelObjectInitializingGrowthModel) = + (initialized_signal=0.0, bindings_remained_dirty=false) + +function PlantSimEngine.run!( + ::ModelObjectInitializingGrowthModel, + status, + environment, + constants=nothing, + context=nothing, +) + model = runtime_model(context) + ObjectId(:initialized_leaf) in object_ids(model; scale=:Leaf) && + return nothing + leaf = register_object!( + model, + Object( + :initialized_leaf; + scale=:Leaf, + parent=:plant_1, + status=Status(signal=0.0), + ), + ) + target = only( + run_call!( + context, + :initializer; + objects=leaf, + publish=false, + ), + ) + status.initialized_signal = target.status.signal + status.bindings_remained_dirty = Advanced.bindings_dirty(model) + 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, status, environment, constants=nothing, context=nothing) + model = runtime_model(context) + if isapprox(context.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, status, environment, constants=nothing, context=nothing) + if isapprox(context.time, 1.0) + update_geometry!(runtime_model(context), :leaf_1, (cell=:cell_b,)) + status.move_count += 1 + end + return nothing +end + +mutable struct ModelObjectGridBackend <: PlantSimEngine.EnvironmentAPI.AbstractEnvironmentBackend + binds::Vector{Any} + index_updates::Vector{Any} + removed_index_ids::Vector{Any} +end + +ModelObjectGridBackend(binds::Vector{Any}=Any[]) = + ModelObjectGridBackend(binds, Any[], Any[]) + +struct ModelObjectEnvironmentHandle + provider::Symbol + sink::Union{Nothing,Symbol} + cell::Symbol + application::Symbol + process::Symbol +end + +struct ModelObjectEnvironmentField{V,T} + values::V + co2::T +end + +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=Required(Vector{ModelObjectDualLike{BigFloat}}),) +PlantSimEngine.outputs_(::ModelObjectDualLikeSumModel) = + (total=zero(ModelObjectDualLike{BigFloat}),) + +function PlantSimEngine.run!( + ::ModelObjectDualLikeSumModel, + status, + environment, + constants=nothing, + context=nothing, +) + status.total = sum(status.values) + return nothing +end + +PlantSimEngine.EnvironmentAPI.base_step_seconds(::ModelObjectGridBackend) = 3600.0 +PlantSimEngine.EnvironmentAPI.get_nsteps(::ModelObjectGridBackend) = 1 +PlantSimEngine.EnvironmentAPI.environment_variables(::ModelObjectGridBackend) = Set([:T, :CO2]) + +function PlantSimEngine.EnvironmentAPI.bind_environment( + backend::ModelObjectGridBackend, + object::Object, + context::EnvironmentContext, + config, +) + object_geometry = geometry(object) + cell = isnothing(object_geometry) ? :global : object_geometry.cell + provider = isnothing(config) ? :model : Symbol(get(config, :provider, :model)) + sink = isnothing(config) || !haskey(config, :sink) ? nothing : Symbol(config.sink) + handle = ModelObjectEnvironmentHandle( + provider, + sink, + cell, + context.application, + context.process, + ) + push!( + backend.binds, + ( + object=object.id.value, + application=context.application, + cell=cell, + config=config, + ), + ) + return handle +end + +function PlantSimEngine.EnvironmentAPI.update_index!( + backend::ModelObjectGridBackend, + entities, + removed_object_ids, +) + 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 + ], + ) + push!( + backend.removed_index_ids, + [object_id.value for object_id in removed_object_ids], + ) + return nothing +end + +mutable struct ModelObjectMutableEnvironmentBackend <: PlantSimEngine.EnvironmentAPI.AbstractEnvironmentBackend + values::Dict{Symbol,Float64} + writes::Vector{Any} +end + +ModelObjectMutableEnvironmentBackend(values::Pair...) = + ModelObjectMutableEnvironmentBackend(Dict{Symbol,Float64}(values), Any[]) + +PlantSimEngine.EnvironmentAPI.base_step_seconds(::ModelObjectMutableEnvironmentBackend) = 3600.0 +PlantSimEngine.EnvironmentAPI.get_nsteps(::ModelObjectMutableEnvironmentBackend) = 1 +PlantSimEngine.EnvironmentAPI.environment_variables(::ModelObjectMutableEnvironmentBackend) = Set([:T, :CO2]) + +function PlantSimEngine.EnvironmentAPI.bind_environment( + backend::ModelObjectMutableEnvironmentBackend, + object::Object, + context::EnvironmentContext, + config, +) + object_geometry = geometry(object) + cell = isnothing(object_geometry) ? :global : object_geometry.cell + provider = isnothing(config) ? :model : Symbol(get(config, :provider, :model)) + sink = isnothing(config) || !haskey(config, :sink) ? nothing : Symbol(config.sink) + return ModelObjectEnvironmentHandle( + provider, + sink, + cell, + context.application, + context.process, + ) +end + +function PlantSimEngine.EnvironmentAPI.sample( + backend::ModelObjectMutableEnvironmentBackend, + handle::ModelObjectEnvironmentHandle, + variable::Symbol, + time, +) + variable == :CO2 && return 410.0 + variable == :T || error("Unexpected variable `$(variable)`.") + return backend.values[handle.cell] +end + +function PlantSimEngine.EnvironmentAPI.sample( + backend::ModelObjectMutableEnvironmentBackend, + handle::ModelObjectEnvironmentHandle, + state::NamedTuple, + variable::Symbol, + time, +) + hasproperty(state, variable) || error("Transient test environment does not provide `$(variable)`.") + return getproperty(state, variable) +end + +function PlantSimEngine.EnvironmentAPI.sample( + backend::ModelObjectMutableEnvironmentBackend, + handle::ModelObjectEnvironmentHandle, + state::ModelObjectEnvironmentField, + variable::Symbol, + time, +) + variable == :CO2 && return state.co2 + variable == :T || error("Unexpected variable `$(variable)`.") + return state.values[handle.cell] +end + +function PlantSimEngine.EnvironmentAPI.commit_environment!( + backend::ModelObjectMutableEnvironmentBackend, + handle::ModelObjectEnvironmentHandle, + state, + time, +) + hasproperty(state, :T) || error("Committed test environment must provide `T`.") + isnothing(handle.sink) && error("Test environment handle has no commit sink.") + backend.values[handle.cell] = state.T + push!( + backend.writes, + ( + application=handle.application, + process=handle.process, + cell=handle.cell, + environment=state, + 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, on=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]") + @test_throws "Unsupported object selector keyword" One(sacle=:Leaf) + @test_throws "object-query" resolve_object_ids( + selector_scene, + One(scale=:Leaf, var=:leaf_area), + ) + 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, on=One(scale=:Plant)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:leaf_consumer, on=One(scale=:Leaf), inputs=(:signal => One( + Relation(:parent), + application=:plant_signal, + 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, on=Many(scale=:Leaf)), + ModelSpec(ModelObjectPlantSignalSumModel(); name=:plant_total, on=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=(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, + 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_application=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, + application=: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, on=Many(scale=:Leaf)), + ModelSpec(ModelObjectSignalCallerModel(); name=:signal_caller, on=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 isnothing(address.application) + @test address.var == :leaf_area + @test address.policy isa Integrate + @test address.window == Day(1) + @test !address.from_status + @test isnothing(address.after) + @test address.multiplicity == :many + + status_address = object_address( + One( + Relation(:parent); + scale=:Plant, + var=:water, + from_status=true, + after=("soil", :weather), + ), + ) + @test status_address.scope === nothing + @test status_address.relation == :parent + @test status_address.var == :water + @test status_address.from_status + @test status_address.after == (:soil, :weather) + + keyword_selector = One(kind=:plant, scale=:Leaf) + @test isempty(keyword_selector.criteria.selectors) + @test keyword_selector.criteria.kind == :plant + @test keyword_selector.criteria.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) + @test_throws "application-target" ModelSpec( + m; + on=One(scale=:Leaf, var=:leaf_area), + ) + @test_throws "no current object" ModelSpec( + m; + on=One(scale=:Leaf, within=Self()), + ) + @test_throws "application-target" ModelSpec( + m; + on=One(Relation(:parent)), + ) + @test_throws "call-binding" ModelSpec( + m; + calls=(:stomata => One(scale=:Leaf, var=:leaf_area),), + ) + @test_throws "output-request" OutputRequest( + One(scale=:Leaf, application=:leaf_energy), + :leaf_area, + ) + spec = ModelSpec(m; name=:leaf_energy, on=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, application=:stomata)), every=Hour(1), environment=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).application == :stomata + @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(); on=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, + ( + ModelSpec( + ModelObjectDefaultInputConsumerModel(); + on=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, + ( + ModelSpec( + ModelObjectDefaultInputConsumerModel(); + on=One(scale=:Plant), + inputs=( + :leaf_carbon => + Many(scale=:Leaf, var=:carbon_override), + ), + ), + ), + ) + @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)) + + @test_throws "`process=` in scenario" ModelSpec( + ModelObjectDefaultCallConsumerModel(); + calls=( + :stomata => + One(scale=:Internode, process=:water_status), + ), + ) + override_call_spec = ModelSpec( + ModelObjectDefaultCallConsumerModel(); + calls=( + :stomata => + One(scale=:Internode, application=: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.application == + :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, on=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, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalSetModel(2.0); name=:manual_source, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:manual_consumer, on=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, on=Many(scale=:Leaf)), + ModelSpec(ModelObjectLeafEnergyModel(); name=:leaf_energy, on=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 + + 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 only(execution_rows).call_capability == :compiled_calls + @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, on=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 + @test length(batch_plan.groups) == 1 + @test only(batch_plan.groups).application.id == :batch_counter + @test only(batch_plan.groups).batches == batch_plan.batches + @test only(explain_execution_plan(batch_plan)).call_capability == :no_calls + 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, on=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, on=Many(scale=:Leaf)), + ModelSpec(ModelObjectStomataModel(); name=:shaded_stomata, on=Many(scale=:Leaf)), + ModelSpec(ModelObjectLeafEnergyModel(); name=:leaf_energy, on=Many(scale=:Leaf)), + ) + @test_throws ErrorException Advanced.compile_composite_model(selector_scene, ambiguous_call_specs) + + disambiguated_call_specs = ( + ModelSpec(ModelObjectStomataModel(); name=:sunlit_stomata, on=Many(scale=:Leaf)), + ModelSpec(ModelObjectStomataModel(); name=:shaded_stomata, on=Many(scale=:Leaf), updates=Updates(:gs; after=:sunlit_stomata)), + ModelSpec(ModelObjectLeafEnergyModel(); name=:leaf_energy, on=Many(scale=:Leaf), inputs=(:leaf_areas => Many(scale=:Leaf, within=SelfPlant(), var=:leaf_area)), calls=(:stomata => One(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, on=One(scale=:Scene), inputs=(:optional_signal => OptionalOne( + scale=:Leaf, + within=SceneScope(), + application=:missing_optional_source, + var=:optional_signal, + ),)), + ModelSpec(ModelObjectOptionalCallConsumerModel(); + name=:optional_call_consumer, on=One(scale=:Scene), calls=(:optional_source => OptionalOne( + scale=:Leaf, + within=SceneScope(), + application=: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, on=One(scale=:Leaf), inputs=(:renamed_signal => One( + scale=:Leaf, + within=Subtree(), + application=:signal_source, + var=:signal, + ),)), + ModelSpec(ModelObjectSignalSetModel(12.5); name=:signal_source, on=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, on=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, on=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, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer, on=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, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer, on=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 + + previous_value_after_producer_scene = CompositeModel( + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + status=Status(signal=0.0, observed_signal=0.0), + ); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:signal_source, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:lagged_consumer, on=One(scale=:Leaf), inputs=(PreviousTimeStep(:signal) => One( + within=Self(), + application=:signal_source, + var=:signal, + ),)), + ), + ) + previous_value_compiled = + Advanced.refresh_bindings!(previous_value_after_producer_scene) + @test previous_value_compiled.application_order == + [:signal_source, :lagged_consumer] + run!(previous_value_after_producer_scene; steps=3) + previous_value_status = + only(model_objects(previous_value_after_producer_scene; scale=:Leaf)).status + @test previous_value_status.observed_signal == 2.0 + @test previous_value_status.signal == 3.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, on=One(scale=:Leaf)), + ModelSpec(ModelObjectCycleBModel(); name=:cycle_b, on=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, on=One(scale=:Leaf), inputs=(PreviousTimeStep(:cycle_b) => One( + scale=:Leaf, + application=:cycle_b, + var=:cycle_b, + ),)), + ModelSpec(ModelObjectCycleBModel(); name=:cycle_b, on=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, on=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, on=One(scale=:Leaf), inputs=(:cycle_b => One( + scale=:Leaf, + application=:cycle_b, + var=:cycle_b, + policy=PreviousTimeStep(:other), + ),)), + ModelSpec(ModelObjectCycleBModel(); name=:cycle_b, on=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, on=One(scale=:Leaf)), + ), + ) + @test_throws ErrorException Advanced.compile_composite_model( + inferred_input_scene, + ( + ModelSpec(ModelObjectSignalSourceModel(); name=:sunlit_signal, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalSourceModel(); name=:shaded_signal, on=One(scale=:Leaf), updates=Updates(:signal; after=:sunlit_signal)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer, on=One(scale=:Leaf)), + ), + ) + + filtered_input_specs = ( + ModelSpec(ModelObjectSignalSourceModel(); name=:signal_source, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer, on=One(scale=:Leaf), inputs=(:signal => One(scale=:Leaf, var=:signal, 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 isnothing(filtered_binding.process) + @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, on=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, on=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, on=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, on=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, on=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, on=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, + status=Status(leaf_area=0.0), + ), + Object( + :leaf_2; + scale=:Leaf, + kind=:plant, + species=:oil_palm, + parent=:axis_1, + status=Status(leaf_area=0.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=0.0), + ), + 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, + status=Status(leaf_area=0.0), + ); + 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, on=Many(scale=:Leaf), environment=Environment(provider=:grid)), + ModelSpec(ModelObjectEnvironmentUpdateModel(); name=:temperature_update, on=Many(scale=:Leaf), environment=Environment(provider=:grid, sink=: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))].handle.cell == :cell_a + @test compiled_environment.by_target[(:temperature_update, ObjectId(:leaf_2))].handle.cell == :cell_b + @test length(grid_backend.binds) == 4 + @test length(grid_backend.index_updates) == 1 + @test length(grid_backend.index_updates[1]) == 4 + @test grid_backend.removed_index_ids == [Symbol[]] + @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.handle.provider == :grid + @test leaf_1_probe.handle.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.handle.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_environment_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene); + applications=( + ModelSpec(ModelObjectEnvironmentCO2ProbeModel(); name=:co2_probe, on=One(scale=:Leaf), environment=Environment(provider=:global)), + ), + environment=(T=20.0,), + ) + @test_throws "co2_probe" validate_environment_inputs(missing_global_environment_scene) + @test_throws "Composite model environment is missing required source inputs" Advanced.refresh_environment_bindings!(missing_global_environment_scene) + @test_throws "source `CO2`" Advanced.refresh_environment_bindings!(missing_global_environment_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, on=One(scale=:Leaf), environment=Environment(backend=application_environment_backend)), + ), + environment=(T=20.0,), + ) + @test validate_environment_inputs(application_environment_scene) === nothing + @test validate_environment_inputs(Advanced.refresh_bindings!(application_environment_scene)) === + nothing + @test_throws "CO2" validate_environment_inputs( + Advanced.refresh_bindings!(application_environment_scene), + (T=20.0,), + ) + + remapped_global_environment_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene); + applications=( + ModelSpec(ModelObjectEnvironmentCO2ProbeModel(); name=:co2_probe, on=One(scale=:Leaf), environment=Environment(provider=:global, sources=(CO2=:Ca,))), + ), + environment=(T=20.0, Ca=415.0), + ) + @test validate_environment_inputs(remapped_global_environment_scene) === nothing + @test_throws "Ca" validate_environment_inputs( + Advanced.refresh_bindings!(remapped_global_environment_scene), + (T=20.0, CO2=415.0), + ) + @test validate_environment_inputs( + Advanced.refresh_bindings!(remapped_global_environment_scene), + (T=20.0, Ca=415.0), + ) === nothing + remapped_environment = Advanced.refresh_environment_bindings!(remapped_global_environment_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_environment_scene) + remapped_status = only(model_objects(remapped_global_environment_scene; scale=:Leaf)).status + @test remapped_status.temperature_seen == 20.0 + @test remapped_status.co2_seen == 415.0 + + hinted_global_environment_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene); + applications=( + ModelSpec(ModelObjectEnvironmentCO2HintProbeModel(); name=:co2_probe, on=One(scale=:Leaf), environment=Environment(provider=:global)), + ), + environment=(T=21.0, Ca=420.0), + ) + @test validate_environment_inputs(hinted_global_environment_scene) === nothing + hinted_environment = Advanced.refresh_environment_bindings!(hinted_global_environment_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_environment_scene).applications_by_id[:co2_probe] + @test environment_bindings(hinted_application.spec).CO2.source == :Ca + run!(hinted_global_environment_scene) + hinted_status = only(model_objects(hinted_global_environment_scene; scale=:Leaf)).status + @test hinted_status.temperature_seen == 21.0 + @test hinted_status.co2_seen == 420.0 + + hinted_override_global_environment_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:scene); + applications=( + ModelSpec(ModelObjectEnvironmentCO2HintProbeModel(); name=:co2_probe, on=One(scale=:Leaf), environment=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_environment_scene)) + ) + @test hinted_override_row.source_inputs == [:T, :Cb] + hinted_override_application = + Advanced.refresh_bindings!(hinted_override_global_environment_scene).applications_by_id[:co2_probe] + @test environment_bindings(hinted_override_application.spec).CO2.source == :Cb + @test environment_bindings(hinted_override_application.spec).CO2.reducer isa MeanReducer + run!(hinted_override_global_environment_scene) + hinted_override_status = + only(model_objects(hinted_override_global_environment_scene; scale=:Leaf)).status + @test hinted_override_status.temperature_seen == 22.0 + @test hinted_override_status.co2_seen == 430.0 + + if PlantSimEngine._has_environment_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, on=Many(scale=:Leaf), every=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, on=One(scale=:Leaf), every=Hour(2), environment=Environment(provider=:global, sources=(CO2=:Cb,))), + ), + environment=windowed_weather, + ) + windowed_override_application = + Advanced.refresh_bindings!(windowed_override_scene).applications_by_id[:aggregated_probe] + @test environment_bindings(windowed_override_application.spec).CO2.source == :Cb + @test environment_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, on=One(scale=:Leaf), environment=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 Advanced.refresh_environment_bindings!( + contract_scene, + contract_compiled, + ) === original_contract_bindings + @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, on=One(scale=:Leaf), environment=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.handle.cell == original_contract_binding.handle.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 only(grid_backend.index_updates[2]).id == :leaf_2 + @test isempty(grid_backend.removed_index_ids[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).handle.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 only(grid_backend.index_updates[3]).id == :leaf_1 + @test isempty(grid_backend.removed_index_ids[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).handle.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) == 10 + @test length(grid_backend.index_updates) == 4 + @test only(grid_backend.index_updates[4]).id == :leaf_3 + @test isempty(grid_backend.removed_index_ids[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).handle.cell == :cell_d + + remove_object!(environment_scene, :leaf_3) + refreshed_without_leaf = Advanced.refresh_environment_bindings!(environment_scene) + @test length(grid_backend.index_updates) == 5 + @test isempty(grid_backend.index_updates[5]) + @test grid_backend.removed_index_ids[5] == [:leaf_3] + @test all( + row.object_id != :leaf_3 + for row in explain_environment_bindings(refreshed_without_leaf) + ) + + 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, on=Many(scale=:Leaf), environment=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.handle.cell == :cell_a + @test inherited_row.geometry_source == :ancestor + @test inherited_row.geometry_source_object_id == :plant_1 + @test positioned_row.handle.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 + ).handle.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, on=Many(scale=:Leaf), environment=Environment(provider=:grid, sink=:grid)), + ModelSpec(ModelObjectEnvironmentProbeModel(); name=:probe_after_update, on=Many(scale=:Leaf), environment=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, environment=(T=21.0, CO2=410.0), time=1), + (application=:temperature_update_runtime, process=:model_object_environment_update, cell=:cell_b, environment=(T=31.0, CO2=410.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 + + trial_update_backend = ModelObjectMutableEnvironmentBackend(:cell_a => 20.0) + trial_update_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + geometry=(cell=:cell_a,), + status=Status(temperature_update=0.0, called_temperature=0.0), + ); + applications=( + ModelSpec(ModelObjectEnvironmentUpdateModel(); name=:temperature_update_runtime, on=One(scale=:Leaf), environment=Environment(provider=:grid, sink=:grid)), + ModelSpec(ModelObjectEnvironmentUpdateCallerModel(); name=:temperature_update_caller, on=One(scale=:Leaf), environment=Environment(provider=:grid), calls=(:updater => One(scale=:Leaf, application=:temperature_update_runtime))), + ), + environment=trial_update_backend, + ) + run!(trial_update_scene) + trial_update_status = only(model_objects(trial_update_scene; scale=:Leaf)).status + @test trial_update_status.temperature_update == 21.0 + @test trial_update_status.called_temperature == 21.0 + @test trial_update_backend.values[:cell_a] == 20.0 + @test isempty(trial_update_backend.writes) + + 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, on=Many(scale=:Leaf), inputs=(:leaf_areas => Many(scale=:Leaf, within=SelfPlant(), var=:leaf_area))), + ModelSpec(ModelObjectEnvironmentProbeModel(); name=:probe_runtime, on=Many(scale=:Leaf), environment=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 + + unrelated_call_counter = Ref(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, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalCallerModel(); name=:signal_caller, on=One(scale=:Leaf)), + ModelSpec(ModelObjectBatchCounterModel(unrelated_call_counter); + name=:unrelated_no_call, on=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 + @test unrelated_call_counter[] == 1 + 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 + call_execution = explain_execution_plan(call_runtime_scene) + @test only( + row for row in call_execution + if row.application_id == :signal_caller + ).call_capability == :compiled_calls + @test only( + row for row in call_execution + if row.application_id == :unrelated_no_call + ).call_capability == :no_calls + + hard_call_environment_backend = ModelObjectMutableEnvironmentBackend(:cell_a => 20.0) + hard_call_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_seen=0.0, called_temperature=0.0), + ); + applications=( + ModelSpec(ModelObjectEnvironmentCallSourceModel(); name=:environment_source, on=One(scale=:Leaf), environment=Environment(provider=:grid)), + ModelSpec(ModelObjectEnvironmentCallControllerModel(31.5, false); name=:environment_controller, on=One(scale=:Leaf), environment=Environment(provider=:grid, sink=:grid), calls=(:source => One(scale=:Leaf, application=:environment_source))), + ), + environment=hard_call_environment_backend, + ) + run!(hard_call_environment_scene) + hard_call_environment_status = only(model_objects(hard_call_environment_scene; scale=:Leaf)).status + @test hard_call_environment_status.temperature_seen == 31.5 + @test hard_call_environment_status.called_temperature == 31.5 + @test hard_call_environment_backend.values[:cell_a] == 20.0 + @test isempty(hard_call_environment_backend.writes) + + spatial_trial_backend = + ModelObjectMutableEnvironmentBackend(:cell_a => 20.0, :cell_b => 21.0) + spatial_trial_state = ModelObjectEnvironmentField( + Dict(:cell_a => 30.5, :cell_b => 34.0), + 410.0, + ) + spatial_trial_scene = CompositeModel( + Object( + :scene; + scale=:Scene, + kind=:scene, + status=Status(called_targets=0), + ), + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + parent=:scene, + geometry=(cell=:cell_a,), + status=Status(temperature_seen=0.0), + ), + Object( + :leaf_2; + scale=:Leaf, + kind=:plant, + parent=:scene, + geometry=(cell=:cell_b,), + status=Status(temperature_seen=0.0), + ); + applications=( + ModelSpec(ModelObjectEnvironmentCallSourceModel(); name=:environment_source, on=Many(scale=:Leaf), environment=Environment(provider=:grid)), + ModelSpec(ModelObjectSpatialEnvironmentCallControllerModel(spatial_trial_state); + name=:spatial_environment_controller, on=One(scale=:Scene), environment=Environment(provider=:grid), calls=(:source => Many(scale=:Leaf, process=:model_object_environment_call_source))), + ), + environment=spatial_trial_backend, + ) + run!(spatial_trial_scene) + spatial_trial_statuses = Dict( + object.id.value => object.status + for object in model_objects(spatial_trial_scene) + ) + @test spatial_trial_statuses[:scene].called_targets == 2 + @test spatial_trial_statuses[:leaf_1].temperature_seen == 30.5 + @test spatial_trial_statuses[:leaf_2].temperature_seen == 34.0 + @test spatial_trial_backend.values == Dict(:cell_a => 20.0, :cell_b => 21.0) + @test isempty(spatial_trial_backend.writes) + + publish_hard_call_environment_backend = ModelObjectMutableEnvironmentBackend(:cell_a => 20.0) + publish_hard_call_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_seen=0.0, called_temperature=0.0), + ); + applications=( + ModelSpec(ModelObjectEnvironmentCallSourceModel(); name=:environment_source, on=One(scale=:Leaf), environment=Environment(provider=:grid)), + ModelSpec(ModelObjectEnvironmentCallControllerModel(32.5, true); name=:environment_controller, on=One(scale=:Leaf), environment=Environment(provider=:grid, sink=:grid), calls=(:source => One(scale=:Leaf, application=:environment_source))), + ), + environment=publish_hard_call_environment_backend, + ) + run!(publish_hard_call_environment_scene) + publish_hard_call_environment_status = only(model_objects(publish_hard_call_environment_scene; scale=:Leaf)).status + @test publish_hard_call_environment_status.temperature_seen == 32.5 + @test publish_hard_call_environment_status.called_temperature == 32.5 + @test publish_hard_call_environment_backend.values[:cell_a] == 32.5 + @test publish_hard_call_environment_backend.writes == [ + (application=:environment_controller, process=:model_object_environment_call_controller, cell=:cell_a, environment=(T=32.5, CO2=410.0), 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(ModelObjectEnvironmentCallSourceModel(); name=:environment_source, on=One(scale=:Leaf), environment=Environment(provider=:grid)), + ModelSpec(ModelObjectIterativeEnvironmentCallControllerModel((30.0, 31.0), 32.0); + name=:environment_controller, on=One(scale=:Leaf), environment=Environment(provider=:grid, sink=:grid), calls=(:source => One(scale=:Leaf, application=:environment_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] == 32.0 + @test iterative_hard_call_backend.writes == [ + ( + application=:environment_controller, + process=:model_object_iterative_environment_call_controller, + cell=:cell_a, + environment=(T=32.0, CO2=410.0), + time=1, + ), + ] + accepted_call_samples = outputs(iterative_hard_call_sim)[ + (:environment_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, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalSourceModel(); name=:signal_source, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalCallerModel(); name=:signal_caller, on=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, on=One(scale=:Leaf), every=Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:scene_temporal_sum, on=One(scale=:Scene), inputs=(:signal_sum => One(scale=:Leaf, var=:signal, policy=Integrate(), window=Hour(2))), every=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, on=One(scale=:Leaf), every=Hour(1)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_observer, on=One(scale=:Leaf)), + ), + environment=(duration=Hour(1),), + ) + tracked_output_request = OutputRequest( + :Leaf, + :signal; + name=:signal_two_hour, + application=:hourly_signal, + 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, on=One(scale=:Leaf), every=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, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_observer, on=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, on=One(scale=:Leaf), every=Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:scene_temporal_sum, on=One(scale=:Scene), inputs=(:signal_sum => One( + scale=:Leaf, + var=:signal, + policy=Integrate(), + window=Hour(2), + ),), every=Hour(2)), + ), + environment=(duration=Hour(1),), + ) + selective_temporal_request = OutputRequest( + :Scene, + :temporal_total; + name=:temporal_total_two_hour, + application=:scene_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, on=One(scale=:Leaf), every=Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:scene_temporal_sum, on=One(scale=:Scene), inputs=(:signal_sum => One( + scale=:Leaf, + var=:signal, + policy=Integrate(), + window=Hour(2), + ),), every=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 bounded_source_samples isa + PlantSimEngine.TemporalDependencyBuffer{Float64} + @test length(bounded_source_samples.times) == 2 + @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] + bounded_requested_samples = outputs(bounded_temporal_simulation)[ + (:scene_temporal_sum, ObjectId(:scene), :temporal_total) + ] + @test bounded_requested_samples isa Vector{Tuple{Float64,Float64}} + @test length(bounded_requested_samples) == 10 + @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, on=One(scale=:Leaf), every=Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:scene_temporal_latest, on=One(scale=:Scene), inputs=(:signal_sum => One(scale=:Leaf, var=:signal, policy=HoldLast(), window=Hour(2))), every=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, on=One(scale=:Leaf), every=Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:trait_policy_consumer, on=One(scale=:Scene), inputs=(:signal_sum => One(scale=:Leaf, var=:signal)), every=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, on=One(scale=:Leaf), every=Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:explicit_policy_consumer, on=One(scale=:Scene), inputs=(:signal_sum => One(scale=:Leaf, var=:signal, policy=Integrate())), every=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, on=One(scale=:Leaf), every=Hour(1)), + ModelSpec(ModelObjectTemporalSumModel(); name=:big_integral, on=One(scale=:Scene), inputs=(:signal_sum => One( + scale=:Leaf, + application=:big_signal, + var=:signal, + policy=Integrate(), + window=Hour(2), + ),), every=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, on=One(scale=:Leaf), every=Hour(2)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:fast_consumer, on=One(scale=:Leaf), inputs=(:signal => One( + scale=:Leaf, + application=:slow_signal, + var=:signal, + policy=Interpolate(), + ),), every=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, on=One(scale=:Leaf), every=Hour(2)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:fast_consumer, on=One(scale=:Leaf), inputs=(:signal => One( + scale=:Leaf, + application=:slow_signal, + var=:signal, + policy=Interpolate(; mode=:hold, extrapolation=:hold), + ),), every=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, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer, on=One(scale=:Leaf), inputs=(:signal => One( + scale=:Leaf, + application=: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(1.0); name=:canonical_signal, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalConsumerModel(); name=:signal_consumer, on=One(scale=:Leaf)), + ModelSpec(ModelObjectSignalSetModel(10.0); name=:stream_signal, on=One(scale=:Leaf), output_routing=(signal=:stream_only,)), + ), + ) + 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.signal == 1.0 + @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_bound_scene = CompositeModel( + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + status=Status(observed_signal=0.0), + ); + applications=( + ModelSpec( + ModelObjectSignalSetModel(10.0); + name=:stream_signal, + on=One(scale=:Leaf), + output_routing=(signal=:stream_only,), + ), + ModelSpec( + ModelObjectSignalConsumerModel(); + name=:signal_consumer, + on=One(scale=:Leaf), + inputs=( + signal=One( + within=Self(), + application=:stream_signal, + var=:signal, + policy=HoldLast(), + ), + ), + ), + ), + ) + stream_bound_compiled = Advanced.refresh_bindings!(stream_bound_scene) + stream_bound_binding = only( + row for row in explain_bindings(stream_bound_compiled) + if row.application_id == :signal_consumer + ) + @test stream_bound_binding.source_application_ids == [:stream_signal] + @test stream_bound_binding.carrier_kind == :temporal_stream + stream_bound_simulation = run!(stream_bound_scene; outputs=:all) + stream_bound_status = + only(model_objects(stream_bound_scene; scale=:Leaf)).status + @test !(:signal in propertynames(stream_bound_status)) + @test stream_bound_status.observed_signal == 10.0 + @test only( + row.value for row in collect_outputs( + stream_bound_simulation, + :leaf_1, + :signal; + sink=nothing, + ) + if row.application_id == :stream_signal + ) == 10.0 + + stateful_stream_scene = CompositeModel( + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + status=Status(signal=0.0), + ); + applications=( + ModelSpec( + ModelObjectSignalSetModel(1.0); + name=:canonical_signal, + on=One(scale=:Leaf), + ), + ModelSpec( + ModelObjectSignalSourceModel(); + name=:stream_increment, + on=One(scale=:Leaf), + output_routing=(signal=:stream_only,), + ), + ), + environment=[(duration=Dates.Hour(1),) for _ in 1:2], + ) + stateful_stream_simulation = + run!(stateful_stream_scene; steps=1, outputs=:all) + step!(stateful_stream_simulation) + @test final_state(stateful_stream_simulation).signal == 1.0 + stateful_signal_rows = collect_outputs( + stateful_stream_simulation, + :leaf_1, + :signal; + sink=nothing, + ) + @test getproperty.( + filter( + row -> row.application_id == :stream_increment, + stateful_signal_rows, + ), + :value, + ) == [1.0, 2.0] + + 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, on=One(scale=:Leaf), output_routing=(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 MethodError OutputRequest( + :Leaf, + :signal; + name=:removed_process_filter, + 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, on=One(scale=:Leaf)) + biomass_pruner = + ModelSpec(ModelObjectBiomassPrunerModel(); name=:leaf_pruning, on=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, + ModelSpec( + ModelObjectBiomassPrunerModel(); + name=:leaf_pruning, + on=One(scale=:Leaf), + updates=Updates(:biomass; after=:water_status), + ), + ), + ) + @test_throws ErrorException Advanced.compile_composite_model( + writer_scene, + ( + biomass_source, + ModelSpec( + ModelObjectBiomassPrunerModel(); + name=:leaf_pruning, + on=One(scale=:Leaf), + updates=Updates(:biomass; after=:model_object_biomass_source), + ), + ), + ) + @test_throws ErrorException Advanced.compile_composite_model( + writer_scene, + ( + ModelSpec( + ModelObjectBiomassPrunerModel(); + name=:leaf_pruning, + on=One(scale=:Leaf), + updates=Updates(:biomass; after=:carbon_allocation), + ), + biomass_source, + ), + ) + + ordered_pruner = ModelSpec( + ModelObjectBiomassPrunerModel(); + name=:leaf_pruning, + on=One(scale=:Leaf), + updates=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 + + writer_template = CompositeModelTemplate(( + ModelSpec( + ModelObjectBiomassSourceModel(); + name=:carbon_allocation, + on=One(scale=:Leaf), + ), + ModelSpec( + ModelObjectBiomassPrunerModel(); + name=:leaf_pruning, + on=One(scale=:Leaf), + updates=Updates(:biomass; after=:carbon_allocation), + ), + )) + mounted_writer_scene = CompositeModel( + Object(:scene; scale=:Scene), + ObjectInstance( + :palm, + writer_template; + root=Object(:plant; scale=:Plant, parent=:scene), + objects=( + Object( + :leaf; + scale=:Leaf, + parent=:plant, + status=Status(biomass=-1.0), + ), + ), + ), + ) + mounted_writer_compiled = Advanced.compile_composite_model(mounted_writer_scene) + mounted_writer_row = only( + row for row in explain_writers(mounted_writer_compiled) + if row.variable == :biomass + ) + @test mounted_writer_row.application_ids == + [:palm__carbon_allocation, :palm__leaf_pruning] + @test mounted_writer_row.update_after == + [:palm__leaf_pruning => [:palm__carbon_allocation]] + + empty_many_scene = CompositeModel( + Object(:scene; scale=:Scene, kind=:scene), + Object( + :plant_1; + scale=:Plant, + kind=:plant, + parent=:scene, + status=Status(signals=Float64[], signal_total=0.0), + ); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); name=:leaf_signal, on=Many(scale=:Leaf)), + ModelSpec(ModelObjectPlantSignalSumModel(); name=:plant_signal_total, on=One(scale=:Plant), inputs=(:signals => Many( + scale=:Leaf, + within=Subtree(), + application=:leaf_signal, + var=:signal, + ),)), + ), + ) + empty_many_compiled = Advanced.refresh_bindings!(empty_many_scene) + empty_many_binding = only(empty_many_compiled.input_bindings) + @test isempty(empty_many_binding.source_ids) + @test empty_many_binding.source_application_ids == [:leaf_signal] + @test empty_many_compiled.application_order == + [:leaf_signal, :plant_signal_total] + register_object!( + empty_many_scene, + Object( + :leaf_1; + scale=:Leaf, + kind=:plant, + status=Status(signal=0.0), + ); + parent=:plant_1, + ) + run!(empty_many_scene) + @test only(model_objects(empty_many_scene; scale=:Plant)).status.signal_total == 1.0 + refreshed_empty_many_binding = only( + binding for binding in + Advanced.refresh_bindings!(empty_many_scene).input_bindings + if binding.application_id == :plant_signal_total + ) + @test refreshed_empty_many_binding.source_ids == [ObjectId(:leaf_1)] + @test refreshed_empty_many_binding.source_application_ids == [:leaf_signal] + + initializing_growth_scene = CompositeModel( + Object( + :scene; + scale=:Scene, + kind=:scene, + status=Status(initialized_signal=0.0), + ), + Object( + :plant_1; + scale=:Plant, + kind=:plant, + parent=:scene, + ); + applications=( + ModelSpec(ModelObjectInitializingGrowthModel(); + name=:initializing_growth, on=One(scale=:Scene), calls=(:initializer => Many( + scale=:Leaf, + application=:dynamic_initializer, + ),)), + ModelSpec(ModelObjectSignalSetModel(7.0); + name=:dynamic_initializer, on=Many(scale=:Leaf)), + ), + environment=(duration=Hour(1),), + ) + initializing_growth_simulation = run!( + initializing_growth_scene; + steps=2, + ) + @test only(model_objects(initializing_growth_scene; scale=:Scene)). + status.initialized_signal == 7.0 + @test only(model_objects(initializing_growth_scene; scale=:Scene)). + status.bindings_remained_dirty + @test only(model_objects(initializing_growth_scene; scale=:Leaf)). + status.signal == 7.0 + @test initializing_growth_simulation.compiled.revision == + Advanced.model_revision(initializing_growth_scene) + @test initializing_growth_simulation.execution_plan.model_revision == + Advanced.model_revision(initializing_growth_scene) + + lifecycle_resume_scene = CompositeModel( + Object( + :scene; + scale=:Scene, + kind=:scene, + status=Status(execution_count=0), + ); + applications=( + ModelSpec(ModelObjectSignalSourceModel(); + name=:new_leaf_signal, on=Many(scale=:Leaf)), + ModelSpec(ModelObjectExecutionCounterModel(); + name=:execution_counter, on=One(scale=:Scene)), + ModelSpec(ModelObjectFirstGrowthModel(); + name=:first_growth, on=One(scale=:Scene)), + ), + environment=(duration=Hour(1),), + ) + run!(lifecycle_resume_scene; steps=1) + @test only(model_objects(lifecycle_resume_scene; scale=:Scene)). + status.execution_count == 1 + @test only(model_objects(lifecycle_resume_scene; scale=:Leaf)). + status.signal == 1.0 + @test Set(object_ids(lifecycle_resume_scene)) == + Set([ + ObjectId(:scene), + ObjectId(:first_growth_leaf), + ]) + + 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, on=One(scale=:Scene)), + ModelSpec(ModelObjectSignalSourceModel(); name=:leaf_signal, on=Many(scale=:Leaf)), + ModelSpec(ModelObjectPlantSignalSumModel(); name=:plant_signal_total, on=One(scale=:Plant), inputs=(:signals => Many(scale=:Leaf, within=Subtree(), var=:signal))), + ModelSpec(ModelObjectPruningModel(); name=:pruning, on=One(scale=:Scene)), + ), + environment=(duration=Hour(1),), + ) + lifecycle_output_request = OutputRequest( + :Leaf, + :signal; + name=:leaf_signal_hourly, + application=:leaf_signal, + 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 == 3.0 + @test only(model_objects(lifecycle_scene; scale=:Plant)).status.signal_total == 6.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) + 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)) == 3 + @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) == 3 + @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, on=One(scale=:Scene)), + ModelSpec(ModelObjectEnvironmentProbeModel(); name=:moving_probe, on=One(scale=:Leaf), environment=Environment(provider=:grid)), + ), + environment=moving_environment_backend, + ) + moving_environment_simulation = run!( + moving_environment_scene; + steps=2, + outputs=:all, + performance=true, + ) + @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_environment_performance = + Advanced.runtime_performance(moving_environment_simulation) + @test moving_environment_performance.counts[ + :execution_targets_constructed + ] == 1 + @test moving_environment_performance.counts[ + :execution_batches_constructed + ] == 0 + @test moving_environment_performance.counts[ + :execution_groups_updated_in_place + ] == 1 + moving_probe_rows = collect_outputs( + moving_environment_simulation, + :leaf_1, + :temperature_seen; + sink=nothing, + ) + @test getproperty.(moving_probe_rows, :value) == [30.0, 30.0] + @test only( + row for row in explain_environment_bindings(moving_environment_simulation.environment_bindings) + if row.application_id == :moving_probe + ).handle.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, on=One(scale=:Leaf), every=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, on=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, on=One(scale=:Leaf), every=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, on=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, on=One(scale=:Leaf), every=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..4f628482e --- /dev/null +++ b/test/test-updates.jl @@ -0,0 +1,102 @@ +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, status, environment, constants=nothing, context=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, status, environment, constants=nothing, context=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, status, environment, constants=nothing, context=nothing) + status.leaf_biomass *= 0.5 + return nothing +end + +struct UpdateBiomassObserverModel <: AbstractUpdate_Biomass_ObserverModel end +PlantSimEngine.inputs_(::UpdateBiomassObserverModel) = (leaf_biomass=Required(Float64),) +PlantSimEngine.outputs_(::UpdateBiomassObserverModel) = (observed_biomass=0.0,) +function PlantSimEngine.run!(::UpdateBiomassObserverModel, status, environment, constants=nothing, context=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(); on=One(scale=:Leaf)), + ModelSpec(UpdateLeafPruningModel(); on=One(scale=:Leaf)), + ), + ) + + model = update_scene( + ModelSpec(UpdateCarbonAllocationModel(); on=One(scale=:Leaf)), + ModelSpec(UpdateLeafPruningModel(); on=One(scale=:Leaf), updates=Updates(:leaf_biomass; after=:update_carbon_allocation)), + ModelSpec(UpdateBiomassObserverModel(); on=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(); on=One(scale=:Leaf)), + ModelSpec(UpdateLeafPruningModel(); on=One(scale=:Leaf), updates=Updates(:leaf_biomass; after=:update_carbon_allocation)), + ModelSpec(UpdateLeafSenescenceModel(); on=One(scale=:Leaf), updates=Updates(:leaf_biomass; after=:update_carbon_allocation)), + ), + ) + + ordered = update_scene( + ModelSpec(UpdateCarbonAllocationModel(); on=One(scale=:Leaf)), + ModelSpec(UpdateLeafSenescenceModel(); on=One(scale=:Leaf), updates=Updates(:leaf_biomass; after=:update_carbon_allocation)), + ModelSpec(UpdateLeafPruningModel(); on=One(scale=:Leaf), updates=Updates(:leaf_biomass; + after=(:update_carbon_allocation, :update_leaf_senescence),)), + ModelSpec(UpdateBiomassObserverModel(); on=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