A canopy energy-balance model may try several air temperatures before deciding which temperature other models should use. A controller manages these repeated calculations. Committing its solution means saving the accepted conditions in the shared environment for later calculations.
This guide first shows how to configure and run an existing controller, then explains its implementation. Understand Environments introduces providers and local conditions; Implement A Hard Dependency explains how one model calls another. Read those pages first if these ideas are new.
Here the controller starts at 20 °C and adds 1 °C until a reader returns a value strictly above 22 °C. These arbitrary choices make the example easy to follow. It is not a physical temperature model: a real solver would calculate new estimates from its equations and stop when a convergence criterion is met.
The example uses three types from PlantSimEngine.Examples:
| Type | Role |
|---|---|
ToySpatialEnvironment | Stores environmental values in named cells. It supplies temperatures and can save an accepted temperature. It is a data provider. |
ToyEnvironmentReaderModel | Copies environment.T to status.temperature_seen. It does not calculate or change the temperature. |
ToyEnvironmentControllerModel | Calls the reader repeatedly, checks its result, and commits the accepted temperature. |
Use one weather observation to initialize a cell named :canopy at 20 °C. A cell is just a named entry in the dictionary here; this example has no 3D mesh. step_seconds=3600.0 sets an hour-long time step, and this toy provider supplies one step. The controller reads its starting temperature from this cell; committing later changes the cell, leaving the original weather observation unchanged.
using Test, PlantSimEngine, DataFrames
using PlantSimEngine.Examples
weather = (T=20.0,)
environment = ToySpatialEnvironment(
Dict(:canopy => (T=weather.T,)); step_seconds=3600.0,
)
environment.cells[:canopy]The reader has no parameters. The controller adds 1 °C after each unsuccessful trial and stops when the reader returns a value strictly above 22 °C. It allows at most ten trial calls in this scenario.
reader = ToyEnvironmentReaderModel()
controller = ToyEnvironmentControllerModel(;
increment=1.0, threshold=22.0, max_iterations=10,
)Both models run on the same leaf. Its geometry=(cell=:canopy,) tells the provider which cell to use. Both Environment configurations below refer to the same environment object, so they share the stored cell values.
The controller also has sink=:cells. A sink is a destination for values written back to the environment. In this toy provider, :cells means "allow accepted values to be saved in the cells dictionary".
| Configuration | What it enables |
|---|---|
Environment(backend=environment) | Read values from the cell associated with the leaf. This is all the reader needs. |
Environment(backend=environment, sink=:cells) | Use the same provider and also allow commit_environment! to save accepted values in that cell. The controller needs this because it commits a temperature. |
The two settings answer different questions: cell=:canopy chooses which cell, while sink=:cells enables writing back to the provider. The name :cells is specific to ToySpatialEnvironment; other providers can define different destinations. It is unrelated to the sink argument used when collecting simulation outputs into a table.
model = CompositeModel(
Object(:leaf; scale=:Leaf, geometry=(cell=:canopy,));
applications=(
ModelSpec(
reader;
name=:reader, on=One(scale=:Leaf),
environment=Environment(backend=environment),
),
ModelSpec(
controller;
name=:controller, on=One(scale=:Leaf),
environment=Environment(backend=environment, sink=:cells),
),
),
)The controller's environment_outputs_ declaration says what it may write (T); its sink configuration says where to write it. Omitting sink=:cells from the controller would make its commit_environment! call fail with a "has no commit sink" error. The reader has neither an environment_outputs_ declaration nor a commit call, so it needs no sink.
The controller's dep declaration asks for a temperature reader on the same object. It selects the reader's process, not its application name name=:reader. The reader runs only when the controller calls it; it does not also run independently. The implementation section below explains this declaration and the loop.
During this one-step simulation, the controller keeps trial results out of the saved output history. Publishing records the reader's result once the controller has accepted it:
| Action | Temperature seen by the reader | Temperature stored in :canopy |
|---|---|---|
| Before the controller runs | The reader has not run yet. | 20 °C |
| Iteration 1: try the starting temperature. | 20 °C; continue. | 20 °C |
| Iteration 2: add 1 °C and try again. | 21 °C; continue. | 20 °C |
| Iteration 3: add 1 °C and try again. | 22 °C; still not above the threshold. | 20 °C |
| Iteration 4: add 1 °C and try again. | 23 °C; stop iterating. | 20 °C |
Commit the solution (T=23.0,). | No reader call in this operation. | 23 °C |
Call the reader at the solution with publish=true. | 23 °C | 23 °C |
simulation = run!(model; outputs=:all)
state = final_state(simulation)
(
initial=state.initial_temperature,
iterations=state.iterations,
accepted=state.accepted_temperature_seen,
committed_temperature=environment.cells[:canopy].T,
)The reader ran four trial calculations and one final calculation, all within one simulation time step. Its output history contains only the final 23 °C result. The controller records the starting temperature and iteration count so you can see how it reached that result.
Use the diagnostics below to inspect which cell each application uses and how many output samples were saved. The reader has only one saved sample, despite being called five times:
select(
DataFrame(Diagnostics.explain_environment_bindings(model)),
:application_id, :object_id, :handle,
)| Row | application_id | object_id | handle |
|---|---|---|---|
| Symbol | Symbol | ToyEnvir… | |
| 1 | controller | leaf | ToyEnvironmentHandle(:canopy, :cells) |
| 2 | reader | leaf | ToyEnvironmentHandle(:canopy, nothing) |
select(
DataFrame(Diagnostics.explain_outputs(simulation)),
:application_id, :variable, :nsamples,
)| Row | application_id | variable | nsamples |
|---|---|---|---|
| Symbol | Symbol | Int64 | |
| 1 | controller | accepted_temperature_seen | 1 |
| 2 | controller | initial_temperature | 1 |
| 3 | controller | iterations | 1 |
| 4 | reader | temperature_seen | 1 |
If you only need to use an existing controller, the configuration above is enough. The following details explain how to write one.
The controller uses ordinary model declarations, plus a declaration of the environment variables it may change:
| Declaration | Meaning in this example |
|---|---|
inputs_ | No values are read from another model's status. |
outputs_ | Store the initial temperature, iteration count, and accepted result on the leaf. |
environment_inputs_ | Read the starting temperature T from the provider. |
environment_outputs_ | Allow the controller to commit T to the provider. This does not add T to the leaf's status. |
dep | Declare a call named reader, selecting one model of process :toy_environment_reader on the same object. |
dep supplies the model's default choice. A scenario can choose a particular application with ModelSpec(...; calls=...), as shown in Implement A Hard Dependency.
The controller starts from environment.T. At each iteration, run_call!(context, :reader; environment=(T=temperature,), publish=false) runs the reader with a temporary temperature. run_call! returns a collection of called targets; only(...) retrieves the one target selected here. The controller reads trial_target.status.temperature_seen to decide whether to stop or add model.increment and try again.
Trial calls leave the stored environment unchanged and do not add samples to output history. They do change the reader's current status: publish=false does not restore rejected trial values. The reader simply overwrites its temperature here. A scientific controller must also handle any accumulated state that a rejected trial changes.
Once a result is above model.threshold, commit_environment! saves the temperature in the provider. The final reader call uses that same temperature with publish=true to save its output. Committing changes the environment; publishing records model results. max_iterations limits the trials and raises an error without committing if no result is accepted.
When accepting a solution, supply every variable declared in environment_outputs_. If another controller calls this one as a trial with publish=false, its nested calls cannot save accepted output samples or change the shared environment.
The complete reader and controller definitions below are read directly from examples/ToySpatialEnvironment.jl when the documentation builds. They include the process declarations, parameters, and run! methods. You do not need to copy them to run this example: using PlantSimEngine.Examples imports them.
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(; increment=1.0, threshold=22.0, max_iterations=100)
Start from the environment temperature and repeatedly add `increment` until
the reader returns a temperature strictly above `threshold`. Commit that
temperature and publish the reader's result only after the loop succeeds.
This arbitrary rule teaches iteration and environment updates; it is not a
physical temperature model. `max_iterations` limits the number of trial calls.
"""
struct ToyEnvironmentControllerModel{T} <:
AbstractToy_Environment_ControllerModel
increment::T
threshold::T
max_iterations::Int
end
function ToyEnvironmentControllerModel(;
increment=1.0,
threshold=22.0,
max_iterations::Integer=100,
)
increment, threshold = promote(float(increment), float(threshold))
isfinite(increment) && increment > zero(increment) || throw(
ArgumentError("increment must be finite and positive."),
)
isfinite(threshold) || throw(ArgumentError("threshold must be finite."))
max_iterations > 0 || throw(ArgumentError("max_iterations must be positive."))
return ToyEnvironmentControllerModel(increment, threshold, Int(max_iterations))
end
PlantSimEngine.inputs_(::ToyEnvironmentControllerModel) = NamedTuple()
PlantSimEngine.dep(::ToyEnvironmentControllerModel) = (
reader=Call(One(process=:toy_environment_reader)),
)
function PlantSimEngine.outputs_(model::ToyEnvironmentControllerModel)
initial = zero(model.threshold)
return (
initial_temperature=initial,
iterations=0,
accepted_temperature_seen=initial,
)
end
PlantSimEngine.environment_inputs_(model::ToyEnvironmentControllerModel) = (
T=zero(model.threshold),
)
PlantSimEngine.environment_outputs_(model::ToyEnvironmentControllerModel) = (
T=zero(model.threshold),
)
function PlantSimEngine.run!(
model::ToyEnvironmentControllerModel,
status,
environment,
constants,
context,
)
temperature = environment.T
status.initial_temperature = temperature
status.iterations = 0
for iteration in 1:model.max_iterations
trial_target = only(run_call!(
context,
:reader;
environment=(T=temperature,),
publish=false,
))
status.iterations = iteration
if trial_target.status.temperature_seen > model.threshold
commit_environment!(context, (T=temperature,))
accepted_target = only(run_call!(
context, :reader; environment=(T=temperature,), publish=true,
))
status.accepted_temperature_seen =
accepted_target.status.temperature_seen
return nothing
end
# A real solver would calculate its next estimate from model results.
temperature = trial_target.status.temperature_seen + model.increment
end
error("Temperature did not exceed the threshold within $(model.max_iterations) iterations.")
endThe provider's bind_environment method remembers the leaf's cell and its application's sink setting in a handle. Its sample methods read either the stored cell or a temporary trial state. Its commit_environment! method checks for sink=:cells and replaces the stored cell with the accepted state. Here that cell contains only T.
"""
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
endA process model can normally use an existing provider. To implement your own, see Environment Backend Extensions.
Understand Environments shows how to give different objects their own local conditions. MAESPA-Style Synthesis combines local leaf conditions with a controller that adjusts canopy air conditions across several plants.