Implementing processes
This page is a step-by-step guide for implementing a new concrete process type in Terrarium. We assume familiarity with the Basic concepts and Core interfaces pages, both of which cover the AbstractProcess interface and variable system. For a concrete, self-contained example that follows the workflow below step by step, see the Implementing a process: 1D linear heat diffusion example.
Control flow
Every process in Terrarium is implemented across three levels of abstraction:
- I. Top-level interface methods for
AbstractProcess, i.e.variables,initialize!,compute_auxiliary!, andcompute_tendencies!(see doc section on theAbstractProcessinterface) - II. Kernel functions; this includes both the
@kernelentry point and subsequently inlined functions with signatures of the formcompute_*(i,j[,k], grid, fields, ::Process, args...) - III. Primitive methods defined directly by the subtype of
AbstractProcess. These are scalar-valued, grid-independent functions corresponding to individual terms or expressions in the mathematical formulation of the process physics.
The flow of execution is I → II → III: top-level interface methods are invoked by the enclosing AbstractModel, these methods then launch! their corresponding @kernels which in turn call the inner kernel functions.
Development workflow
We recommend to start implementing new processes using the following workflow:
- Start by drafting an initial version of the process
structsubtypingAbstractProcess; it's good to start simple by directly putting all relevant parameters into thisstruct. You can later consider grouping these parameters into logical parameterization types. - Define
variables(::Process)for the new process type. Identify which variables areprognostic, which areauxiliary, and which might be assumed to beinputs, provided either by external forcing data or by another process. - Write down unit tests and initial implementations of the scalar-valued process methods (level III). These should correspond to the key equations or terms in the mathematical formulation of the physics. Note that these methods are invoked on each elementary volume and thus cannot be used to define equations involving spatial gradients or fluxes.
- Implement the
compute_*(i, j[, k], grid, fields, ...)kernel functions with the corresponding@kernelentry pointscompute_auxiliary_kernel!andcompute_tendencies_kernel!(level II). Functions which might be independently useful outside of the process interface (e.g.adjust_saturation_profile!inSoilHydrology) can also define their own@kernelentry points. - Finally, implement the top-level process methods for
initialize!,compute_auxiliary!andcompute_tendencies!thatlaunch!their respective@kernels (level I).
As mentioned above, see the example Implementing a process: 1D linear heat diffusion for a detailed walkthrough of this workflow.
Enabling implicit timestepping
By default, all prognostic variables are integrated explicitly. A diffusion-like operator can instead be integrated implicitly under an IMEX timestepper (see Implicit-explicit time stepping). To support this in a process:
- Accept a
Timesteppingargument. Give the process'scompute_tendencies!a trailing positional argumenttimestepping::Timestepping = Explicit()and pass it through the kernel to the kernel functions, which should then dispatch onExplicitorImplicit. The model obtains the resolved class of a variable withtimestepping(state, Val(:name)). Seecompute_tendencies!ofSoilThermodynamicsfor an example. - Omit the implicitly treated terms under
Implicit(). The tendency underImplicit()must contain only the explicitly treated terms (e.g. sources and boundary fluxes). The omitted terms are instead applied via the corresponding implicit operator. TheExplicit()implementation ofcompute_tendencies!should typically remain unchanged so that users also have the option to use explicit timesteppers. - Declare prognostic variables that should be solved implicitly. Define
timestepping(::AbstractVariable{:name}, ::YourModel, ::AbstractIMEX) = Implicit()for the model that owns the variable. - Define the implicit operator. The implicit timestepper calls
implicit_operator(model, state, names)once on the host with the names of all routed variables when its cache is allocated at initialization, and the model returns oneAbstractLinearOperatorfor all of them. Every model with implicitly routed variables must define this method; the fallback throws anArgumentErrorat initialization. To solve the variables one after the other, return aSplitOperatorof newly allocated per-variable operators (e.g. aTridiagonalOperatorfor a vertical diffusion operator) in the order in which they should be solved; a model that couples its variables returns its own operator type instead. - Implement the process hooks. In each iteration, the implicit solver calls the hooks
linearize!,implicit_update!, andimplicit_closure!for the variable, which the model forwards to the process that owns it (the linear solve,solve!, is generic over the operator). Implement them for the process;implicit_closure!can usually forward to the regularclosure!of the process. Their signatures and what each must do are described in Process hooks. The linear system is solved for the variable of the prognostic variable's closure relation, so a closure implementsvarnameif its variable can be integrated implicitly. SeeSoilThermodynamicsandSoilHydrologywithRichardsEqfor examples.
Terrarium.implicit_operator — Function
implicit_operator(model::AbstractModel, state, names::Tuple)Return a newly allocated AbstractLinearOperator for the implicitly routed prognostic variables names of model (e.g. a SplitOperator of per-variable operators in solve order). Called once on the host at initialize; methods throw an ArgumentError for unsupported configurations, as does the fallback. SoilModel and LandModel forward to implicit_operator(soil::AbstractSoil, state, grid, names).
implicit_operator(
hydrology::Terrarium.AbstractSoilHydrology,
state,
grid
)Fallback for hydrology processes without an implicit operator; throws an ArgumentError.
implicit_operator(
hydrology::SoilHydrology{NF, RichardsEq, SaturationClosure, SoilHydraulics} where {SaturationClosure<:Terrarium.AbstractSoilWaterClosure, SoilHydraulics<:(Terrarium.AbstractSoilHydraulics{NF})},
state,
grid
) where NFAllocate the TridiagonalOperator of saturation_water_ice on ground_domain(grid). Throws an ArgumentError unless the closure is the SoilSaturationPressureClosure and pressure_head has no value boundary conditions.
The operator is the backward Euler step of the Richards equation in the total pressure head Ψ, after the mass-conservative modified Picard iteration of [3]: with porosity φ, saturation ξ, and ξ⁺ = ξⁿ + Δt G_exp from the explicit stage, the water content is linearized about the iterate m, φ ξᵐ⁺¹ ≈ φ ξᵐ + Cᵐ (Ψᵐ⁺¹ - Ψᵐ) with the specific moisture capacity Cᵐ and the hydraulic conductivity Kᵐ lagged, giving the tridiagonal system Cᵐ Ψᵐ⁺¹ - Δt ∂z(Kᵐ ∂z Ψᵐ⁺¹) = Cᵐ Ψᵐ - φ (ξᵐ - ξ⁺) (see the soil hydrology docs for the derivation). Each Picard iteration m = 0, …, M-1, starting from ξ⁰ = ξⁿ, calls the hooks:
linearize!: hydraulic conductivity, laggedKᵐ(faces) andCᵐ(centers, bounded below byminimum_moisture_capacityso that frozen saturated cells stay regular), and the right-hand side;solve!: the system above forΨᵐ⁺¹;implicit_update!:ξᵐ⁺¹ = ξ⁺ + Δt ∂z(Kᵐ ∂z Ψᵐ⁺¹) / φthrough the interior faces, which conserves water exactly, andwater_flux ← -Kᵐ ∂z Ψᵐ⁺¹, the flux with which the heat step advects heat;implicit_closure!: redistribution of over- and undersaturation, water table,Ψᵐ⁺¹, and hydraulic conductivity. Since every iteration restarts fromξ⁺, only the final one routes surface excess water to the runoff pool and moves the heat of redistributed water.
The solver calls the hooks through the SoilModel/LandModel forwarding methods and the SoilEnergyWaterCarbon methods in soil_coupled.jl.
References
- [3] Celia, Bouloutas, and Zarba, Water Resources Research 26, 1483–1496 (1990)
implicit_operator(
energy::SoilThermodynamics,
hydrology::Terrarium.AbstractSoilHydrology,
state,
grid
)Return the implicit operator of the soil internal energy: a TridiagonalOperator on ground_domain(grid) for heat conduction in temperature, with a face velocity for upwind advection of heat by the water flux if the hydrology lets the water flow (SoilHydrology with RichardsEq). Validates on the host that the freeze curve is FreeWater (the only one with an implemented regularized apparent heat capacity) and that temperature has no value boundary conditions (not yet supported). Flux boundary conditions on internal_energy and gradient boundary conditions on temperature are supported; the latter prescribe the diffusive flux -κ ∂T/∂z through the boundary face, which enters the explicit tendency via compute_boundary_energy_tendency.
The operator is the backward Euler step of ∂U/∂t = ∂z(κ ∂z T) - ∂z(C_w T j_w) + G_exp in temperature, with first-order upwind advection by the vertical water flux j_w (T_up the temperature of the upwind cell of a face) and a conservative update of the energy (see the soil energy docs for the derivation). With the Picard solver, for iterations m = 0, …, M-1, starting from U⁰ = Uⁿ (plus any heat moved by the closure of the water step) and T⁰ ← closure(U⁰):
linearize!:Tᵐ ← closure(Uᵐ), laggedκᵐ(faces), regularized apparent heat capacityC_appᵐ(centers), advective face velocityC_w j_w(faces), andrhs = C_appᵐ Tᵐ - Uᵐ + U⁺withU⁺ = U⁰ + Δt G_exp;solve!:C_appᵐ Tᵐ⁺¹ - Δt ∂z(κᵐ ∂z Tᵐ⁺¹) + Δt ∂z(C_w j_w T_upᵐ⁺¹) = rhs;implicit_update!:Uᵐ⁺¹ = U⁺ + Δt [∂z(κᵐ ∂z Tᵐ⁺¹) - ∂z(C_w j_w T_upᵐ⁺¹)]through the interior faces only;implicit_closure!:Tᵐ⁺¹and the liquid water fraction← closure(Uᵐ⁺¹).
The solver calls the hooks through the SoilModel/LandModel forwarding methods and the SoilEnergyWaterCarbon methods in soil_coupled.jl.
implicit_operator(
soil::Terrarium.AbstractSoil,
state,
grid,
names::Tuple{Vararg{Symbol}}
) -> Terrarium.SplitOperatorReturn the implicit operator of the soil prognostic variables names on grid: a SplitOperator of per-variable operators that solves saturation_water_ice before internal_energy, whatever the order of names. The heat step then advects heat with the water flux of the current step (water_flux, see advective_heat_flux) and sees the end-of-step saturation, while the Richards step sees the beginning-of-step temperature and liquid water fraction (the effect of frozen ground on the hydraulic conductivity is lagged by one step). Other names follow in their given order and are rejected by the per-variable fallback.
implicit_operator(soil::AbstractSoil, state, grid, ::Val{name})Return the implicit operator of the soil prognostic variable name on grid. SoilEnergyWaterCarbon returns that of the energy process for internal_energy and of the hydrology process for saturation_water_ice; this fallback throws an ArgumentError.
implicit_operator(snow::AbstractSnow, state, grid, ::Val{name})Return the implicit operator of the snow prognostic variable name: a DiagonalOperator for the meltwater drainage of snow_water_equivalent (see linearize!). The fallback throws an ArgumentError.
implicit_operator(
snow::SingleLayerSnow,
state,
grid,
::Val{:snow_water_equivalent}
) -> DiagonalOperatorAllocate the DiagonalOperator of the implicit meltwater drainage of snow_water_equivalent, solved in the liquid fraction snow_liquid_fraction (see implicit_variable).
Drainage removes liquid water at the zero-enthalpy reference (liquid water at 0 °C), so it changes neither the snow energy Ū nor the ice content W_i = (1 − θ) W = −Ū / (ρ_w L_f), clamped to [0, W]. With Ū⁺ and W⁺ = Wⁿ + Δt G_exp from the explicit stage (snowfall, rain, and sublimation), the backward Euler step of the drainage M_r(θ, W) (compute_meltwater_outflow) is a scalar equation per column in the liquid fraction θ, via W = W_i / (1 − θ):
G(θ) = W_i / (1 − θ) + Δt M_r(θ, W_i / (1 − θ)) − W⁺ = 0.Each Picard iteration takes one Newton step: linearizing G about the iterate θᵐ, G(θ) ≈ G(θᵐ) + G′(θᵐ) (θ − θᵐ), and setting this to zero gives the diagonal system G′(θᵐ) θᵐ⁺¹ = G′(θᵐ) θᵐ − G(θᵐ) (see the snow mass balance docs for the convergence argument). Each iteration calls the hooks:
linearize!: the diagonalG′(θᵐ)and right-hand sideG′(θᵐ) θᵐ − G(θᵐ), at the liquid fractionθᵐof the current iterateWᵐ, bounded to[0, θ⁺];solve!:θᵐ⁺¹insnow_liquid_fraction;implicit_update!:Wᵐ⁺¹ = W_i / (1 − θᵐ⁺¹)withmin(L_c, θ⁺) ≤ θᵐ⁺¹ ≤ θ⁺, since the iteration starts fromWⁿ, which may lie below the root, andmeltwater_flux ← (W⁺ − Wᵐ⁺¹) / Δt, the drainage passed on to the surface hydrology in the next step;implicit_closure!: the snow closure at the new iterate.
A fully liquid pack (W_i = 0, θ = 1) drains at min(K_sat, (1 − L_c) W / τ_m), so its step has the exact solution max(W⁺ − Δt K_sat, W⁺ / (1 + Δt (1 − L_c) / τ_m)), independent of the iterate.
Terrarium.timestepping — Function
timestepping(timestepper::AbstractTimeStepper)::TimesteppingReturn the Timestepping trait (Explicit or Implicit) of the given timestepper. Every concrete timestepper must define this trait (e.g. timestepping(::ForwardEuler) = Explicit()); there is no default so that a new scheme declares its class explicitly. It is used, among other things, to route each sub-stepper of an AbstractIMEX to its slice of the IMEXCache.
timestepping(var::AbstractVariable, model::AbstractModel, timestepper::AbstractTimeStepper)::TimesteppingReturn the Timestepping class with which the prognostic variable var of model is integrated under timestepper. Defaults to Explicit() for all variables; specialize this method (typically on an AbstractIMEX timestepper together with particular variable and/or model types) to route selected variables to the implicit sub-stepper.
timestepping(state::StateVariables, ::Val{name})
timestepping(state::StateVariables, var::AbstractVariable{name})Return the resolved Timestepping class (Explicit or Implicit) of the prognostic variable name in state. Under an AbstractIMEX timestepper the class is read from the IMEXCache type parameter in state.timestepper_cache; for any other cache every variable is Explicit(), as are names that are not top-level prognostic variables (including variables in namespaces, which an IMEX does not route).
The lookup is resolved at compile time (type stable), so the trait can be passed to process methods (e.g. compute_tendencies!) that dispatch on it.
timestepping(timestepper::Optional{AbstractTimeStepper}, state::StateVariables, ::Val{name})Return the Timestepping class with which the prognostic variable name of state is integrated by timestepper, for process methods that receive the model's timestepper. A single timestepper uses its own class (see timestepping(timestepper)), an AbstractIMEX the per-variable class in its IMEXCache (see timestepping(state, ::Val)), and nothing (e.g. standalone process evaluation) gives Explicit(). Type stable.
timestepping(cache::IMEXCache)Return the resolved timestepping classes (a tuple of Explicit/Implicit instances in prognostic-variable order) held in the IMEXCache type parameter.
Terrarium.linearize! — Function
linearize!(operator::AbstractLinearOperator, rhs, state, model::AbstractModel, explicit_stage, Δt, ::Iteration{name})Process hook of the implicit solver. Fill the lagged coefficients of operator and the right-hand side rhs for variable name at the current iterate in state, given the explicit stage explicit_stage (u⁺ = uⁿ + Δt G_exp). The fallback throws an ArgumentError.
linearize!(
operator::TridiagonalOperator,
rhs,
state,
grid,
hydrology::SoilHydrology{NF, RichardsEq, SaturationClosure, SoilHydraulics} where {SaturationClosure<:Terrarium.AbstractSoilWaterClosure, SoilHydraulics<:(Terrarium.AbstractSoilHydraulics{NF})},
soil::Terrarium.AbstractSoil,
constants::PhysicalConstants,
explicit_saturation,
Δt
) where NFUpdate the hydraulic conductivity (compute_hydraulics!), fill the lagged capacity and face conductivity of operator (compute_implicit_water_flow_coefficients!), and assemble the right-hand side C Ψᵐ - φ (ξᵐ - ξ⁺) in rhs (compute_implicit_water_flow_rhs), with ξ⁺ = explicit_saturation. [3] recommend at least two iterations.
References
- [3] Celia et al., Water Resources Research 26, 1483–1496 (1990)
linearize!(
operator::TridiagonalOperator,
rhs,
state,
grid,
energy::SoilThermodynamics,
soil::Terrarium.AbstractSoil,
constants::PhysicalConstants,
explicit_energy,
Δt
)Linearize the implicit heat operator of the soil internal energy at the current iterate (linearly implicit backward Euler in temperature, conservative update of the internal energy). On entry, explicit_energy holds U⁺ = U⁰ + Δt G_exp, where G_exp contains the flux boundary conditions (e.g. SoilHeatFlux and GeothermalHeatFlux), the diffusive fluxes implied by gradient boundary conditions on temperature, the advective heat fluxes through the boundary faces, and the heat carried by water crossing the boundaries (compute_boundary_advected_heat!), but not the fluxes through the interior faces. This hook
- re-evaluates the energy closure, so that temperature and liquid water fraction are consistent with the internal energy and the current saturation (changed by the water step since
tⁿ), - evaluates the face conductivity
κ(compute_implicit_conductivity!), the regularized apparent heat capacityC_app(compute_implicit_capacity!), and, with water flow, the advective face velocityC_w j_wfrom thewater_fluxof the step (compute_implicit_advective_velocity!), storing them in theface_conductivity,diagonal_coefficient, andface_velocityofoperator, - and assembles the right-hand side
C_app Tᵐ - Uᵐ + U⁺ofC_app Tᵐ⁺¹ - Δt ∂z(κ ∂z Tᵐ⁺¹) + Δt ∂z(C_w j_w T_upᵐ⁺¹) = C_app Tᵐ - Uᵐ + U⁺(compute_implicit_energy_rhs).
implicit_update! then updates the internal energy conservatively: for any number of iterations, the change of column-integrated energy is exactly Δt times the net boundary flux in G_exp.
linearize!(
operator::DiagonalOperator,
rhs,
state,
grid,
snow::SingleLayerSnow,
constants::PhysicalConstants,
explicit_water,
Δt,
::Terrarium.Iteration{:snow_water_equivalent}
)Fill the diagonal and right-hand side of the Newton step for the liquid fraction of the meltwater drainage (compute_implicit_meltwater_coefficients!), given the explicit stage W⁺ = explicit_water.
Terrarium.implicit_update! — Function
implicit_update!(state, model::AbstractModel, operator::AbstractLinearOperator, explicit_stage, Δt, ::Iteration{name})Process hook of the implicit solver. Update the prognostic variable name in state conservatively from the solved target and explicit_stage, using the fluxes implied by operator.
Terrarium.implicit_closure! — Function
implicit_closure!(state, model::AbstractModel, ::Iteration{name, target, final})Process hook of the implicit solver. Re-evaluate the closure of variable name (and the auxiliary variables the next linearization needs) after implicit_update!, for the process that owns the variable only. A model typically forwards it to the regular process closure! of that process; only a closure with once-per-step side effects (e.g. routing excess water to runoff) needs its own method, which must restrict them to final = true (see the soil hydrology for an example).