Choosing a time stepper

This example shows how to choose the time stepping scheme of a SoilModel and compares the accuracy and stability of the three schemes Terrarium provides: the explicit ForwardEuler and Heun schemes, and the implicit BackwardEuler scheme, used through an IMEX timestepper. See Time stepping for the details of each scheme.

using Terrarium
# For plotting
import CairoMakie as Makie
import DisplayAs

We use a single column with 20 exponentially spaced soil layers, the thinnest of which is 5 cm thick at the surface:

grid = ColumnGrid(CPU(), Float64, ExponentialSpacing(Δz_min = 0.05, N = 20))
1×1×20 RectilinearGrid{Float64, Periodic, Flat, Bounded} on CPU with 1×0×3 halo
├── Periodic x ∈ [0.0, 1.0)      regularly spaced with Δx=1.0
├── Flat y                       
└── Bounded  z ∈ [-303.194, 0.0] variably spaced with min(Δz)=0.05, max(Δz)=100.0

Setting the time stepper

The time stepper is a property of the model, set with the timestepper keyword argument. Without it, SoilModel chooses a default that depends on the soil hydrology: an IMEX timestepper that integrates heat conduction and water flow implicitly if the water flow follows the Richards equation (the default), and ForwardEuler if the soil water is immobile (NoFlow).

model = SoilModel(grid)
model.timestepper
IMEX{Float64, ForwardEuler{Float64}, BackwardEuler{Float64, Picard}}(ForwardEuler{Float64}(3600.0), BackwardEuler{Float64, Picard}(Picard(2)))

The explicit schemes take a default time step Δt (in seconds):

SoilModel(grid; timestepper = ForwardEuler(Float64; Δt = 60)).timestepper
ForwardEuler{Float64}(60.0)
SoilModel(grid; timestepper = Heun(Float64; Δt = 60)).timestepper
Heun{Float64}(60.0)

BackwardEuler is an implicit scheme and is always used as the implicit partner of an IMEX timestepper, which integrates soil heat conduction (and Richards-equation water flow) implicitly and all other terms explicitly with its explicit partner. The IMEX timestepper takes its default time step from the explicit partner:

timestepper = IMEX(ForwardEuler(Float64; Δt = 3600), BackwardEuler(Float64))
SoilModel(grid; timestepper).timestepper
IMEX{Float64, ForwardEuler{Float64}, BackwardEuler{Float64, Picard}}(ForwardEuler{Float64}(3600.0), BackwardEuler{Float64, Picard}(Picard(2)))

The default time step can always be overridden by passing Δt to timestep! or run!, as we will do below.

Comparing the schemes

We compare the three schemes in three experiments, each run with each scheme and a range of time steps. The following function runs one simulation of ndays days of a soil model with the given soil processes, timestepper, and time step Δt, and returns the temperature and saturation profiles at the end of each day:

const day = 86400.0

function simulate(timestepper, Δt, soil; boundary_conditions, initializers, ndays = 4)
    model = SoilModel(grid; soil, timestepper)
    integrator = initialize(model; boundary_conditions, initializers)
    steps_per_day = round(Int, day / Δt)
    temperature = zeros(grid.Nz, ndays)
    saturation = zeros(grid.Nz, ndays)
    for n in 1:ndays
        run!(integrator; steps = steps_per_day, Δt)
        temperature[:, n] .= interior(integrator.state.temperature)[1, 1, :]
        saturation[:, n] .= interior(integrator.state.saturation_water_ice)[1, 1, :]
    end
    return (; temperature, saturation)
end
simulate (generic function with 1 method)

As a reference, we use ForwardEuler with a time step of one second, far below its stability limit. We test time steps from one minute to one day, spaced exponentially. Each time step is rounded to the nearest divisor of one day, so that every simulation reaches the end of each day exactly:

divisors_of_day = filter(d -> day % d == 0, 60:Int(day))
targets = exp.(range(log(60), log(day), length = 13))
Δts = unique(map(t -> divisors_of_day[argmin(abs.(log.(divisors_of_day) .- log(t)))], targets))
13-element Vector{Int64}:
    60
   108
   200
   360
   675
  1200
  2160
  4320
  7200
 14400
 28800
 43200
 86400

We add a time step of 10 seconds, below the stability limits of both experiments, to see the convergence of the explicit schemes more clearly:

pushfirst!(Δts, 10)
14-element Vector{Int64}:
    10
    60
   108
   200
   360
   675
  1200
  2160
  4320
  7200
 14400
 28800
 43200
 86400

The error of each simulation is the root-mean-square difference from the reference over all layers and days:

rms_error(solution, reference) = sqrt(sum(abs2, solution .- reference) / length(reference))

timesteppers = [
    "ForwardEuler" => ForwardEuler(Float64),
    "Heun" => Heun(Float64),
    "BackwardEuler" => IMEX(ForwardEuler(Float64), BackwardEuler(Float64)),
]
3-element Vector{Pair{String, Terrarium.AbstractTimeStepper{Float64}}}:
  "ForwardEuler" => ForwardEuler{Float64}(300.0)
          "Heun" => Heun{Float64}(300.0)
 "BackwardEuler" => IMEX{Float64, ForwardEuler{Float64}, BackwardEuler{Float64, Picard}}(ForwardEuler{Float64}(300.0), BackwardEuler{Float64, Picard}(Picard(2)))

The following function runs a simulation with run_simulation(timestepper, Δt) for the reference and for each scheme and time step, and returns the errors:

function compare_timesteppers(run_simulation)
    reference = run_simulation(ForwardEuler(Float64), 1.0)
    solutions = Dict(name => [run_simulation(timestepper, Δt) for Δt in Δts] for (name, timestepper) in timesteppers)
    temperature_errors = Dict(name => [rms_error(s.temperature, reference.temperature) for s in solutions[name]] for (name, _) in timesteppers)
    saturation_errors = Dict(name => [rms_error(s.saturation, reference.saturation) for s in solutions[name]] for (name, _) in timesteppers)
    return (; temperature_errors, saturation_errors)
end
compare_timesteppers (generic function with 1 method)

The explicit schemes are only stable below the diffusive stability limit of the thinnest layer, which is of the order of its diffusion time scale (see cell_diffusion_timescale). Beyond this limit, the explicit solutions oscillate. Latent heat keeps some of them bounded near 0 °C, but most grow by many orders of magnitude or are not finite at all, and we omit those from the plots. Since the saturation stays bounded even when the solution diverges, we judge divergence by the temperature error alone:

diverged(temperature_error) = !isfinite(temperature_error) || temperature_error > 100
diverged (generic function with 1 method)

On logarithmic axes, a scheme of order p follows a straight line of slope p: ForwardEuler and BackwardEuler are first order, and Heun is second order. The following function plots the errors of each scheme against the time step, with the diffusion time scale as a dashed line. It omits the runs whose temperature error diverged, and all longer time steps of the same scheme, since a single step beyond the stability limit can stay bounded without being meaningful:

function plot_errors!(ax, errors, temperature_errors, diffusion_timescale)
    for (name, _) in timesteppers
        mask = cumprod(.!diverged.(temperature_errors[name])) .== 1
        Makie.scatterlines!(ax, Δts[mask], errors[name][mask]; label = name)
    end
    Makie.vlines!(ax, diffusion_timescale; color = :gray, linestyle = :dash, label = "Diffusion time scale")
    return ax
end

error_axis(position; kwargs...) = Makie.Axis(position; xscale = log10, yscale = log10, xlabel = "Time step / s", kwargs...)
error_axis (generic function with 1 method)

Experiment 1: freezing without water flow

We first consider the default soil without water flow. We drive the column with a ground heat flux that cools the soil by 40 W/m² on average, overlaid with a daily cycle with an amplitude of 100 W/m². SoilHeatFlux is positive upward (out of the soil), so the soil cools most strongly at midnight and warms around noon. Implicit time stepping does not (yet) support value boundary conditions such as PrescribedSurfaceTemperature, so we prescribe a flux instead.

surface_heat_flux(x, t, p) = p.mean + p.amplitude * cos(2π * t / p.period)
heat_flux = SoilHeatFlux(surface_heat_flux; parameters = (mean = 40.0, amplitude = 100.0, period = day))
(internal_energy = (top = FluxBoundaryCondition: ContinuousBoundaryFunction surface_heat_flux at (Nothing, Nothing, Nothing),),)

The wet column starts just above 0 °C, so that a freezing front enters the soil, and the top layers freeze and partially thaw again over each daily cycle. The latent heat released or absorbed at the front makes this a much harder test than heat conduction alone.

soil = SoilEnergyWaterCarbon(Float64; hydrology = SoilHydrology(Float64, NoFlow()))
initializers = (temperature = 0.5, saturation_water_ice = 0.8)
@time errors = compare_timesteppers((timestepper, Δt) -> simulate(timestepper, Δt, soil; boundary_conditions = heat_flux, initializers))
(temperature_errors = Dict("Heun" => [2.8267255587848562e-6, 6.041819456721263e-6, 1.3241950138589928e-5, 4.2018111808466765e-5, 0.00013352858713365075, 0.0005898806571208695, 0.0029995267062355602, 0.049291483575320996, 0.365201558146616, 0.3654164444942771, 0.2733896679977598, 1.2295978370616099e19, 6.415732220777397e18, 9.224024511602902e12], "ForwardEuler" => [2.4613874622361477e-5, 0.0001599250266384877, 0.00028717925067004053, 0.0005260379669472404, 0.0009123237103624452, 0.001580947884488588, 0.0023805068343352295, 0.021663201747989327, 0.5949734385053872, 0.3653019758183135, 1.4221378840411394e12, 1.2952011188533533e7, 1.5865564341145438e8, 2.572194030911707e6], "BackwardEuler" => [5.861204568604566e-5, 0.00034474387508522266, 0.0006308321684988367, 0.0012067157622626928, 0.002313489832904073, 0.004838216443378066, 0.010241551954573564, 0.02372830075240073, 0.07078672806031927, 0.15226761384942863, 0.31564417720979693, 0.3654542077706363, 0.36694055919495777, 2.8855944331747194]), saturation_errors = Dict("Heun" => [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], "ForwardEuler" => [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], "BackwardEuler" => [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]))

Frozen soil conducts heat better and stores less of it than thawed soil, so the stability limit is set by the frozen column:

frozen_integrator = initialize(
    SoilModel(grid; soil, timestepper = ForwardEuler(Float64));
    initializers = (temperature = -3.0, saturation_water_ice = 0.8),
)
diffusion_timescale = Terrarium.cell_diffusion_timescale(frozen_integrator)
1089.8303309941023

We plot the temperature errors:

let fig = Makie.Figure(size = (800, 500))
    Makie.Label(fig[0, 1], "Error on SoilModel without hydrology relative to ForwardEuler with Δt = 1 s"; fontsize = 16, font = :bold)
    ax = error_axis(fig[1, 1]; ylabel = "RMS temperature error / K")
    plot_errors!(ax, errors.temperature_errors, errors.temperature_errors, diffusion_timescale)
    Makie.axislegend(ax; position = :lt)
    DisplayAs.PNG(fig)
end

Below the stability limit, Heun is by far the most accurate scheme, until the phase change limits its convergence near the limit. At the smallest steps, its error flattens at about 3×10⁻⁶ K, the error of the reference itself: ForwardEuler at one second is only first order, with an error of about a tenth of its error at ten seconds. Beyond it, only BackwardEuler remains reliable: its error grows linearly with the time step and stays below 0.1 K for steps of about an hour. Larger steps resolve the daily cycle poorly, and a step of one day sees only the strongest cooling at midnight, since the explicit stage evaluates the surface flux at the beginning of each step.

Experiment 2: thawing of an active layer with Richards-equation water flow

We now let the soil water move according to the Richards equation. To know the porosity exactly, we use a homogeneous mineral soil with a constant porosity of 0.4 and no organic carbon:

porosity = 0.4
richards_soil = SoilEnergyWaterCarbon(
    Float64;
    hydrology = SoilHydrology(Float64, RichardsEq()),
    strat = HomogeneousSoilStratigraphy(Float64; porosity = ConstantSoilPorosity(Float64; mineral_porosity = porosity)),
    biogeochem = ConstantSoilCarbonDensity(Float64; ρ_soc = 0.0),
)
SoilEnergyWaterCarbon{Float64}
├── Processes:
├──── strat:  SoilStratigraphy{Float64, 1, Tuple{ConstantSoilHorizon{Float64, :soil, ConstantSoilPorosity{Float64}}}}
├──── energy:  SoilThermodynamics{Float64, SoilEnergyTemperatureClosure, SoilThermalProperties{Float64, FreeWater, InverseQuadratic, SoilThermalConductivities{Float64}}}
├──── hydrology:  SoilHydrology{Float64, RichardsEq, SoilSaturationPressureClosure, SoilHydraulicsSURFEX{Float64, BrooksCorey{FreezeCurves.SoilWaterVolume{Float64, Float64, Float64}, Float64, Float64}, UnsatKBrooksCorey{Float64}}, Nothing}
├──── biogeochem:  ConstantSoilCarbonDensity{Float64}

We consider the thawing of the active layer above permafrost in early summer. The soil is thawed in the top 10 cm and frozen below, and a ground heat flux warms it from the surface, by 60 W/m² on average with a daily cycle of ±50 W/m², so that the thaw front moves down into the frozen ground:

thaw_heat_flux = SoilHeatFlux(surface_heat_flux; parameters = (mean = -60.0, amplitude = 50.0, period = day))
(internal_energy = (top = FluxBoundaryCondition: ContinuousBoundaryFunction surface_heat_flux at (Nothing, Nothing, Nothing),),)

Water infiltrates at the surface at a constant rate of 2 mm per day, and the bottom of the column is impermeable. InfiltrationFlux is a flux of saturation, positive upward, so the infiltration rate is divided by the porosity and negated. We combine the boundary conditions of both variables with merge_boundary_conditions:

infiltration = 2.0e-3 / day
richards_boundary_conditions = merge_boundary_conditions(
    thaw_heat_flux,
    InfiltrationFlux(-infiltration / porosity),
    ImpermeableBoundary(),
)
(internal_energy = (top = FluxBoundaryCondition: ContinuousBoundaryFunction surface_heat_flux at (Nothing, Nothing, Nothing),), saturation_water_ice = (top = FluxBoundaryCondition: -5.78704e-8, bottom = FluxBoundaryCondition: nothing))

The temperature decreases from 2 °C at the surface to 0 °C at 10 cm depth and −3 °C at 25 cm depth, and stays at −3 °C below. The saturation is 50% at the surface and increases linearly to full saturation at a water table at 5 m depth:

initial_temperature(x, z) = max(-3.0, 2.0 + 20.0 * z)
initial_saturation(x, z) = min(1.0, 0.5 - 0.1 * z)
richards_initializers = (temperature = initial_temperature, saturation_water_ice = initial_saturation)

@time richards_errors = compare_timesteppers(
    (timestepper, Δt) -> simulate(
        timestepper, Δt, richards_soil;
        boundary_conditions = richards_boundary_conditions,
        initializers = richards_initializers,
    )
)
(temperature_errors = Dict("Heun" => [7.896702587339688e-6, 8.371517476264406e-6, 9.806289861390951e-6, 1.6940212672210267e-5, 4.404916408667957e-5, 0.00015289735923564965, 0.0005496393952962994, 0.7733755722415782, 1.519213669854286e27, 1.730639871568559e65, 2.4635568592127944e47, 8.20025389272782e29, 6.231831564570541e21, 5.729138769826013e12], "ForwardEuler" => [7.098441162041234e-5, 0.00046584374946070765, 0.000845758927014747, 0.0015761471658275784, 0.0028531274363020015, 0.00539184271519384, 0.009709280951131362, 0.782422515921921, 9.637660533218416e16, 9.474414304221176e36, 2.1175104306340804e25, 1.0187634033309395e15, 2.6338634279988113e10, 901920.628863171], "BackwardEuler" => [0.00023603333468132857, 0.0014329991240052491, 0.0025827264337464307, 0.0047879842168710406, 0.008628110316589398, 0.016207730952546774, 0.028886738257527276, 0.05221693794499962, 0.10530348965300815, 0.17700745569635815, 0.3541528039837763, 0.6776082207863111, 1.0992636522264043, 0.8756322679178355]), saturation_errors = Dict("Heun" => [4.678037891444749e-8, 4.595200534401565e-8, 4.8668107283647915e-8, 4.993031883147557e-8, 5.3643605655503456e-8, 1.9792681750257913e-7, 3.410511290137252e-7, 0.0003585181994770585, 0.004649777807566491, 0.0043497530890657145, 0.003957551354838424, 0.005510452279779861, 0.005709750244043985, 0.01097033148026416], "ForwardEuler" => [4.208731504917972e-7, 2.7605973394352225e-6, 5.007290287059973e-6, 9.322044364156066e-6, 1.6748395307026618e-5, 3.175296872507931e-5, 5.661622389912451e-5, 0.0002923155521265986, 0.005357623466280026, 0.004342896351080482, 0.0040335895831793535, 0.005184739217827408, 0.010970331479278414, 0.09968786829048633], "BackwardEuler" => [2.3624718646885674e-7, 1.4835934785520782e-6, 2.6892585554474082e-6, 5.004234165258194e-6, 9.079379327791204e-6, 1.6963804039846388e-5, 3.090562745259849e-5, 5.637224001165526e-5, 0.0001147227347641221, 0.00019267952035614025, 0.00048547376490271787, 0.0012243107685177865, 0.002479572821018684, 0.005611531064601792]))

The stability limit of the explicit Richards equation is much shorter than that of heat conduction:

richards_integrator = initialize(
    SoilModel(grid; soil = richards_soil, timestepper = ForwardEuler(Float64));
    boundary_conditions = richards_boundary_conditions,
    initializers = richards_initializers,
)
richards_diffusion_timescale = Terrarium.cell_diffusion_timescale(richards_integrator)
1496.3816835632867

We plot the errors of both temperature and saturation:

let fig = Makie.Figure(size = (1000, 450))
    Makie.Label(fig[0, 1:2], "Error on SoilModel with Richards flow relative to ForwardEuler with Δt = 1 s"; fontsize = 16, font = :bold)
    ax1 = error_axis(fig[1, 1]; ylabel = "RMS temperature error / K")
    ax2 = error_axis(fig[1, 2]; ylabel = "RMS saturation error")
    plot_errors!(ax1, richards_errors.temperature_errors, richards_errors.temperature_errors, richards_diffusion_timescale)
    plot_errors!(ax2, richards_errors.saturation_errors, richards_errors.temperature_errors, richards_diffusion_timescale)
    Makie.axislegend(ax1; position = :lt)
    DisplayAs.PNG(fig)
end

The explicit Richards equation is stable only for steps of about a minute in this column, so both explicit schemes diverge at every longer step. At one minute, Heun is again by far the most accurate scheme. The implicit scheme remains stable at every step: its temperature error grows roughly linearly with the time step, to about 0.4 K at four hours and 1 K at half a day. The saturation error stays small until steps of about an hour, beyond which the infiltrating water is no longer resolved in time.

The time step of the implicit scheme can thus be chosen for accuracy rather than stability, while that of the explicit schemes is limited by the thinnest layer of the grid and the stiffness of the water flow.

Experiment 3: a land model under a synthetic spring day

Finally, we run a LandModel, in which the surface energy balance and the surface hydrology compute the ground heat flux and the infiltration from the atmospheric conditions. Unlike the prescribed infiltration flux of experiment 2, the infiltration is then limited to what the soil can take, so no water enters frozen soil. We leave out snow and vegetation, and use the soil and the initial state of experiment 2.

The atmospheric conditions are synthetic functions of time for a spring day: the air temperature varies between 1 °C at midnight and 11 °C at noon, the sun shines during the day, and a rain shower of about 3.5 mm falls in the afternoon:

air_temperature(t) = 6.0 - 5.0 * cos(2π * t / day)
shortwave_down(t) = max(0.0, -600.0 * cos(2π * t / day))
longwave_down(t) = 290.0 + 2.0 * air_temperature(t)
rainfall(t) = 2.0e-3 / 3600 * exp(-((mod(t, day) - 0.65day) / 3600)^2)
rainfall (generic function with 1 method)

Input variables, such as the atmospheric conditions, are provided to the model by InputSources, which set them at the start of the simulation and before each step (and each stage of Heun). To use functions of time, we define a small input source that evaluates a function at the time of the clock:

struct FunctionInputSource{NF, name, F} <: InputSource{NF, name}
    f::F
end

FunctionInputSource(::Type{NF}, name::Symbol, f) where {NF} = FunctionInputSource{NF, varpath(name), typeof(f)}(f)

function Terrarium.update_inputs!(inputs, grid, clock, fields, source::FunctionInputSource)
    set!(getproperty(inputs, Terrarium.varname(source)), source.f(clock.time))
    return nothing
end

Terrarium.initialize!(inputs, grid, clock, fields, source::FunctionInputSource) =
    Terrarium.update_inputs!(inputs, grid, clock, fields, source)

The remaining inputs are constant: a specific humidity of 4 g/kg, a wind speed of 3 m/s, standard air pressure, and no snowfall.

weather = InputSources(
    FunctionInputSource(Float64, :air_temperature, air_temperature),
    FunctionInputSource(Float64, :surface_shortwave_down, shortwave_down),
    FunctionInputSource(Float64, :surface_longwave_down, longwave_down),
    FunctionInputSource(Float64, :rainfall, rainfall),
    FunctionInputSource(Float64, :snowfall, t -> 0.0),
    FunctionInputSource(Float64, :specific_humidity, t -> 4.0e-3),
    FunctionInputSource(Float64, :windspeed, t -> 3.0),
    FunctionInputSource(Float64, :air_pressure, t -> 101_325.0),
)
InputSources{Float64, nothing, Tuple{Main.var"##179".FunctionInputSource{Float64, (:air_temperature,), typeof(Main.var"##179".air_temperature)}, Main.var"##179".FunctionInputSource{Float64, (:surface_shortwave_down,), typeof(Main.var"##179".shortwave_down)}, Main.var"##179".FunctionInputSource{Float64, (:surface_longwave_down,), typeof(Main.var"##179".longwave_down)}, Main.var"##179".FunctionInputSource{Float64, (:rainfall,), typeof(Main.var"##179".rainfall)}, Main.var"##179".FunctionInputSource{Float64, (:snowfall,), Main.var"##179".var"#25#26"}, Main.var"##179".FunctionInputSource{Float64, (:specific_humidity,), Main.var"##179".var"#27#28"}, Main.var"##179".FunctionInputSource{Float64, (:windspeed,), Main.var"##179".var"#29#30"}, Main.var"##179".FunctionInputSource{Float64, (:air_pressure,), Main.var"##179".var"#31#32"}}}((Main.var"##179".FunctionInputSource{Float64, (:air_temperature,), typeof(Main.var"##179".air_temperature)}(Main.var"##179".air_temperature), Main.var"##179".FunctionInputSource{Float64, (:surface_shortwave_down,), typeof(Main.var"##179".shortwave_down)}(Main.var"##179".shortwave_down), Main.var"##179".FunctionInputSource{Float64, (:surface_longwave_down,), typeof(Main.var"##179".longwave_down)}(Main.var"##179".longwave_down), Main.var"##179".FunctionInputSource{Float64, (:rainfall,), typeof(Main.var"##179".rainfall)}(Main.var"##179".rainfall), Main.var"##179".FunctionInputSource{Float64, (:snowfall,), Main.var"##179".var"#25#26"}(Main.var"##179".var"#25#26"()), Main.var"##179".FunctionInputSource{Float64, (:specific_humidity,), Main.var"##179".var"#27#28"}(Main.var"##179".var"#27#28"()), Main.var"##179".FunctionInputSource{Float64, (:windspeed,), Main.var"##179".var"#29#30"}(Main.var"##179".var"#29#30"()), Main.var"##179".FunctionInputSource{Float64, (:air_pressure,), Main.var"##179".var"#31#32"}(Main.var"##179".var"#31#32"())))

The following function runs one simulation of ndays days of the land model, and returns the temperature and saturation profiles of the soil at the end of each day. A land model is more expensive per step than a soil model, so we simulate a single day:

function simulate_land(timestepper, Δt; ndays = 1)
    model = LandModel(grid; soil = richards_soil, snow = nothing, vegetation = nothing, timestepper)
    integrator = initialize(model; inputs = weather, initializers = richards_initializers)
    steps_per_day = round(Int, day / Δt)
    temperature = zeros(grid.Nz, ndays)
    saturation = zeros(grid.Nz, ndays)
    for n in 1:ndays
        run!(integrator; steps = steps_per_day, Δt)
        temperature[:, n] .= interior(integrator.state.temperature)[1, 1, :]
        saturation[:, n] .= interior(integrator.state.saturation_water_ice)[1, 1, :]
    end
    return (; temperature, saturation)
end

@time land_errors = compare_timesteppers(simulate_land)

land_integrator = initialize(
    LandModel(grid; soil = richards_soil, snow = nothing, vegetation = nothing, timestepper = ForwardEuler(Float64));
    inputs = weather,
    initializers = richards_initializers,
)
land_diffusion_timescale = Terrarium.cell_diffusion_timescale(land_integrator)

let fig = Makie.Figure(size = (1000, 450))
    Makie.Label(fig[0, 1:2], "Error on LandModel without snow relative to ForwardEuler with Δt = 1 s"; fontsize = 16, font = :bold)
    ax1 = error_axis(fig[1, 1]; ylabel = "RMS temperature error / K")
    ax2 = error_axis(fig[1, 2]; ylabel = "RMS saturation error")
    plot_errors!(ax1, land_errors.temperature_errors, land_errors.temperature_errors, land_diffusion_timescale)
    plot_errors!(ax2, land_errors.saturation_errors, land_errors.temperature_errors, land_diffusion_timescale)
    Makie.axislegend(ax1; position = :lt)
    DisplayAs.PNG(fig)
end

As in experiment 2, the explicit schemes are only stable for steps of about a minute or two, limited by the Richards equation. The implicit scheme is stable at every step, and its errors stay small up to steps of a few hours: about 0.03 K at two hours and 0.14 K at four hours. Beyond that, the errors grow quickly, since the surface energy balance of the land model is explicit: it is evaluated at the beginning of each step and held for the whole step, so a step of eight or twelve hours that starts at midnight sees the radiation of the night for half a day. The small error at a step of one day is a coincidence of sampling the solution only once, at the end of the day. In a land model, the time step of the implicit scheme is therefore limited by the time scales of the explicitly treated surface processes rather than by the stability of the soil.


Julia version and environment information

This example was executed with the following version of Julia:

using InteractiveUtils: versioninfo
versioninfo()
Julia Version 1.13.4
Commit c1ac32ab733 (2026-11-13 17:30 UTC)
Build Info:
  Official https://julialang.org release
Platform Info:
  OS: Linux (x86_64-linux-gnu)
  CPU: 4 × AMD EPYC 7763 64-Core Processor
  WORD_SIZE: 64
  LLVM: libLLVM-20.1.8 (ORCJIT, znver3)
  GC: Built with stock GC
Threads: 1 default, 1 interactive, 1 GC (on 4 virtual cores)
Environment:
  JULIA_DEBUG = Documenter

These were the top-level packages installed in the environment:

import Pkg
Pkg.status()
Status `~/work/Terrarium.jl/Terrarium.jl/docs/Project.toml`
  [c7e460c6] ArgParse v1.2.0
⌃ [052768ef] CUDA v6.4.2
  [13f3f980] CairoMakie v0.15.17
  [0b91fe84] DisplayAs v0.1.6
  [e30172f5] Documenter v1.20.0
  [daee34ce] DocumenterCitations v1.5.0
  [d12716ef] DocumenterInterLinks v1.1.0
  [db073c08] GeoMakie v0.7.18
  [033835bb] JLD2 v0.6.7
  [0f8b85d8] JSON3 v1.14.4 [deprecated]
⌅ [63c18a36] KernelAbstractions v0.9.47
  [98b081ad] Literate v2.21.1
  [ee78f7c6] Makie v0.24.17
  [85f8d34a] NCDatasets v0.14.15
  [904d977b] NumericalEarth v0.9.3
⌅ [9e8cae18] Oceananigans v0.117.2
  [a3a2b9e3] Rasters v0.15.0
  [d1845624] RingGrids v1.0.2
  [80418d68] Terrarium v0.1.11-DEV `~/work/Terrarium.jl/Terrarium.jl`
  [b77e0a4c] InteractiveUtils v1.11.0
  [d6f4376e] Markdown v1.11.0
Info Packages marked with ⌃ and ⌅ have new versions available. Those with ⌃ may be upgradable, but those with ⌅ are restricted by compatibility constraints from upgrading. To see why use `status --outdated`
Info Packages marked with [deprecated] are no longer maintained. Use `status --deprecated` to see more information.

This page was generated using Literate.jl.