Core interfaces
The software architecture of Terrarium.jl is still in a prototype stage and is the subject of ongoing discussion. These pages are a non-exhaustive summary of the current working concept and will be updated accordingly if and when the architecture changes.
Modularity through multiple dispatch
Terrarium relies heavily on a core feature of Julia: multiple dispatch. Multiple dispatch is a programming pattern where methods (functions with a particular set of argument types) are dynamically invoked based on the (runtime) types of all their arguments. This can be contrasted to most object-oriented languages (e.g. Java, C/C++/C#, Python, etc.) where dispatch occurs based on only a single (implicit) argument, typically the type of the "object" (or struct) with which the method is associated.
Multiple dispatch allows us to implement model components based on specific combinations of types. As a concrete example, consider the following method signature from Terrarium's surface hydrology module that computes the aerodynamic resistance beneath the canopy:
aerodynamic_resistance(i, j, grid, fields, atmos::AbstractAtmosphere, evapotranspiration::PALADYNCanopyEvapotranspiration)This method will be executed when the function aerodynamic_resistance is called with any implementation (subtype) of AbstractAtmosphere and the PALADYNCanopyEvapotranspiration evapotranspiration scheme. We could define further dispatches that have different implementations for specific subtypes of AbstractAtmosphere or alternative canopy evapotranspiration schemes which would then be invoked when the user configures a model with that choice of components.
In order to maximize code reuse and ease coupling of different components, Terrarium also defines interfaces for each model component (such as AbstractAtmosphere). These interfaces usually consist of a standard set of methods and behaviors that each component is expected to implement. As an example, AbstractAtmosphere defines a method of the form,
air_temperature(i, j, grid, fields, atmos::AbstractAtmosphere) = fields.air_temperature[i, j]which defaults to assuming that a 2D input Field (see Fields) named air_temperature has been defined and is available as a property of fields. However, alternative implementations could derive this air temperature from other state variables or via some other function, without requiring any changes to the calling code. This kind of interface-based coupling is core to the software design of Terrarium. Method interfaces for individual process and parameterization types are summarized on their respective doc pages. Documentation and method dispatches can also be dynamically queried from the Julia REPL via the help function ?air_temperature or with methods(air_temperature) and methodswith.
Key abstractions
As discussed in Basic concepts, Terrarium revolves around two key abstractions: models and processes. Models represent complete representations of a dynamical system defined over a particular spatial grid while processes represent individual components of that system. Both models and processes share a common method interface:
Terrarium.variables — Function
variables(model::AbstractModel)
variables(process::AbstractProcess)Return a Tuple of AbstractVariables (i.e. PrognosticVariable, AuxiliaryVariable, etc.) defined by the model or process.
Terrarium.initialize! — Function
initialize!(state, model::AbstractModel)Initialize all variables defined in state which are defined by model. This defaults to simply calling initialize!(state, model, get_initializer(model)).
initialize!(state, model::AbstractModel, initializer::AbstractInitializer)Initialize the model state variables using the corresponding initializer. This method only needs to be implemented if initialization routines are necessary in addition to direct field/variable initializers.
initialize!(state, grid, process::AbstractProcess, args...)Initialize all state variables associated with the given process on grid. Implementations of AbstractProcess may define additional args that correspond to different process coupling interfaces.
Terrarium.compute_auxiliary! — Function
compute_auxiliary!(state, model::AbstractModel)Compute updates to all auxiliary variables based on the current prognostic state of the model.
compute_auxiliary!(state, grid, process::AbstractProcess, args...)Compute all auxiliary state variables for the given process on grid. Implementations of AbstractProcess may define additional args that correspond to different process coupling interfaces.
Terrarium.compute_tendencies! — Function
compute_tendencies!(state, model::AbstractModel)Compute tendencies for all prognostic state variables for model stored in the given state. This method should be called after compute_auxiliary!.
compute_tendencies!(state, grid, process::AbstractProcess, args...)Compute the tendencies of all prognostic state variables for the given process on grid. Implementations of AbstractProcess may define additional args that correspond to different process coupling interfaces.
Further details on these interfaces and their practical implementations are given in the following sections.
The AbstractProcess interface
In the example above, both AbstractAtmosphere and PALADYNCanopyEvapotranspiration are examples of processes that subtype the AbstractProcess type.
Implementations of AbstractProcess represent physical processes characterized by:
- Zero or more state
variables that vary spatially across any givengrid, - Zero or more parameters that are spatially constant and defined somewhere within the process
struct, - One or more functions defining equations that compute quantities of interest from both the state variables and parameters defined by the process.
Concrete implementations of AbstractProcess are struct types that typically consist of zero or more parameters or parameter structs (also sometimes referred to as parameterizations).
Default (no-op) implementations of variables and initialize! are provided for convenience. However, to avoid ambiguity, compute_auxiliary! and compute_tendencies! must be defined by each process even if they are not needed.
The required additional args may vary for each type of AbstractProcess; typically they consist of either AbstractProcesses on which the process depends or universal parameter types like PhysicalConstants. These argument types must be clearly documented and standardized for each abstract subtype of AbstractProcess. Changes to these interfaces, e.g. the addition of alternative call patterns with different args, should be made with great care and only when absolutely necessary. It is recommended for implementations to always include trailing args... to ensure forward compatibility.
For more details on the state structure and definition of variables, see the section on State variables below.
The AbstractModel interface
The main difference between a "model" and a "process" in Terrarium lies in the specification of the grid and initializer. Processes should be generally be defined independently from any particular choice of initialization, boundary conditions, and grid. However, it is worth noting that processes can dispatch on specific types of grid, if necessary.
AbstractModel is parameterized by two type arguments:
abstract type AbstractModel{NF, Grid <: AbstractLandGrid{NF}} endwhere NF is the numeric float type (e.g. Float32, Float64) and Grid is the concrete grid type. These type parameters propagate to all concrete subtypes, making the full model type checkable at compile time.
Model type hierarchy
Terrarium defines a hierarchy of abstract model subtypes that correspond to the major components defined by most land surface models:
AbstractModel
└── AbstractGroundModel # general ground (soil/rock) models
└── AbstractSoilModel # soil column models
└── AbstractSurfaceEnergyModel # standalone land-atmosphere energy exchange
└── AbstractSnowModel # standalone snow models
└── AbstractVegetationModel # standalone vegetation models
└── AbstractHydrologyModel # standalone (surface) hydrology models
└── AbstractLandModel # fully coupled land modelsNew component models should subtype the most specific abstract type appropriate for the component being implemented.
Required struct fields and accessor methods
By convention, concrete subtypes of AbstractModel are expected to store at least three standard fields that are accessed via the corresponding accessor methods:
| Field | Accessor | Description |
|---|---|---|
grid | get_grid | The spatial grid defining the model domain |
initializer | get_initializer | An AbstractInitializer defining the initial state |
constants | get_constants | A PhysicalConstants struct |
These accessors are used internally by timesteppers and simulation set-up code. If a model deviates from this convention, the relevant accessor methods outlined above must be overridden for that specific model type.
Terrarium.get_grid — Function
get_grid(model::AbstractModel)::AbstractLandGridReturn the spatial grid associated with the given model.
Terrarium.get_initializer — Function
get_initializer(model::AbstractModel)::AbstractInitializerReturn the initializer associated with the given model.
Terrarium.get_constants — Function
get_constants(model::AbstractModel)::PhysicalConstantsReturn the PhysicalConstants associated with the given model.
Note that subtypes of AbstractModel and AbstractCoupledProcesses also automatically inherit a default implementation of the processes method:
Terrarium.processes — Method
processes(obj::Union{AbstractCoupledProcesses, AbstractModel})Return a tuple of AbstractProceses contained in the given model or coupled processes type. Note that this is a type-stable, @generated function that is compiled for each argument type.
Standard constructor for model types
AbstractModel provides a universal convenience constructor that allows the grid to be passed as the first positional argument even when the concrete struct uses @kwdef:
SoilModel(grid; soil = SoilEnergyWaterCarbon(eltype(grid)), ...)This is equivalent to SoilModel(; grid, soil, ...) and is the recommended calling convention for all model types.
Required method implementations
A concrete AbstractModel subtype must implement at minimum the same basic interface as AbstractProcess:
| Method | Purpose |
|---|---|
initialize!(state, model) | Initialize all state variables from the initializer and any process-level initialization |
compute_auxiliary!(state, model) | Compute all auxiliary (non-prognostic) variables from the current prognostic state |
compute_tendencies!(state, model) | Compute tendencies of all prognostic variables |
Additionally, models with closure relations should implement:
| Method | Purpose |
|---|---|
closure!(state, model) | Apply all forward closure relations |
invclosure!(state, model) | Apply all inverse closure relations |
The typical pattern is to forward each of these model-level calls to the corresponding process-level call, passing the grid and constants explicitly.
The AbstractInitializer interface
Standardized model initialization routines can be defined using the AbstractInitializer interface (see also Initialization). Each implementation of AbstractModel must allow for a user-defined initializer (the type can be constrained where appropriate). The simplest initializer is DefaultInitializer, which is a no-op that leaves all Fields at their default (zero) values. More complex models define their own composite initializers; for example, SoilInitializer composes separate initializers for the energy, hydrology, and biogeochemistry state variables. See Soil model for the full list of available initializer types.
Terrarium.AbstractInitializer — Type
Base type for model initializers. Implementations should provide a dispatch of the initialize!(state, model::M, init::I) method where M corresponds to the model type and I to the initializer. An implementation of get_field_initializers can also be provided which returns a NamedTuple of initializer functions for individual state variable fields.
Closure relations
Some physical processes in Terrarium are described by conservation laws that take the form
\[\frac{\partial g(u)}{\partial t} = F(u, \ldots)\]
where $u$ is the conserved (prognostic) state variable and $g(u)$ is a constitutive relation mapping $u$ to the physical units matching the tendency $F$. A canonical example is the soil thermal energy balance, where the prognostic variable is the volumetric internal energy $U$ (J m⁻³), but the tendency $F$ — the divergence of the heat flux — is most naturally evaluated in terms of temperature $T$ (°C). The mapping $U \leftrightarrow T$ is therefore a closure relation.
AbstractClosureRelation is the base type for all such relations in Terrarium. A process that requires a closure stores its closure as a field and reports it via the closures method (which is auto-generated from the field types). The two primary interface methods are:
Terrarium.closure! — Method
closure!(state, grid, closure::AbstractClosureRelation, process, args...)Apply closure for process on the given grid with additional implementation-specific args.
Terrarium.invclosure! — Method
invclosure!(state, grid, closure::AbstractClosureRelation, process::AbstractProcess, args...)Apply the inverse of closure for the process proc on the given grid with additional implementation-specific args defined by the coupling interface for the process type.
The semantics of the forward vs. inverse closure can be summarized as:
| Method | Direction | Typical use |
|---|---|---|
closure! | conserved variable → closure variable | After each time step, compute the auxiliary variable for the closure (e.g. temperature) from the updated conserved quantity (e.g. internal energy) |
invclosure! | closure variable → conserved variable | During initialization, convert user-supplied values (e.g. an initial temperature profile) into the corresponding conserved quantity (e.g. internal energy) |
Default implementations of both methods are no-ops (do nothing), so processes that do not require a closure relation do need to implement them.
Implementations of AbstractModel should define dispatches of closure! and invclosure! that automatically apply all closure relations defined by their processes:
Terrarium.closure! — Method
closure!(state, model::AbstractModel)Apply all closure relations defined for the given model.
Terrarium.invclosure! — Method
invclosure!(state, model::AbstractModel)Apply the inverse of all closure relations defined for the given model.
These methods provide a unified interface that can be used by timesteppers, callbacks, and user-defined analysis routines.
Soil energy: temperature–enthalpy closure
The SoilEnergyBalance process uses the SoilEnergyTemperatureClosure to relate volumetric internal energy $U$ to temperature $T$ and the liquid water fraction $F_l$:
\[U(T) = T \cdot C(T) - L_f \, \theta_{wi} \, (1 - F_l(T))\]
where $C(T)$ is the temperature-dependent volumetric heat capacity, $L_f$ is the volumetric latent heat of fusion, and $\theta_{wi}$ is the total water-ice content.
closure!(state, grid, ::SoilEnergyTemperatureClosure, energy, ground, constants)— evaluates the forward mapping $U \mapsto T$, updatingstate.temperatureandstate.liquid_water_fraction(both auxiliary variables).invclosure!(state, grid, ::SoilEnergyTemperatureClosure, energy, ground, constants)— evaluates the inverse mapping $T \mapsto U$, updatingstate.internal_energy(the prognostic variable) andstate.liquid_water_fraction. This direction is used during initialization when a temperature profile is given and must be converted to internal energy.
See the Soil energy balance doc page for further details and the full list of dispatch signatures.
Soil hydrology: saturation–pressure closure
The SoilHydrology process (Richards equation variant) uses the SoilSaturationPressureClosure to relate total water–ice saturation $S$ to hydraulic head $\Psi$ via the soil-water retention curve (SWRC):
\[\Psi = \Psi_m(S) + \Psi_z + \Psi_h\]
where $\Psi_m$ is the matric potential given by the SWRC, $\Psi_z$ is the elevation head, and $\Psi_h$ is the hydrostatic head contributed by free water above the water table.
closure!(state, grid, ::SoilSaturationPressureClosure, hydrology, soil)— evaluates the forward mapping $S \mapsto \Psi$, updating the auxiliarystate.pressure_head.invclosure!(state, grid, ::SoilSaturationPressureClosure, hydrology, soil)— evaluates the inverse mapping $\Psi \mapsto S$, updating the prognosticstate.saturation_water_ice.
See the Soil hydrology doc page for further details and the full list of dispatch signatures.
Implementing a new closure
To add a closure relation to a new process:
- Define a concrete subtype of
AbstractClosureRelation(or a process-specific abstract subtype, e.g.AbstractSoilEnergyClosure) - Implement
variables(::MyClosureRelation)which should, at minimum, define the relevantauxiliaryvariable for the closure (temperatureandpressure_headfor soil energy and hydrology respectively) - Implement
closure!andinvclosure!dispatching on the process type - Store an instance of the closure as a field of the process
struct; theclosuresgenerated function will then pick it up automatically