Heat equation tutorial

In this tutorial, we'll be solving the heat equation:

$\frac{∂ ρcT}{∂ t} + ∇ ⋅ (-α ∇ρcT) = 0$

where

  • t is time
  • α is the thermal diffusivity
  • T is the temperature
  • ρ is the density
  • c is the heat capacity
  • ρcT is the thermal energy

To put this in the form of ClimateMachine's BalanceLaw, we'll re-write the equation as:

$\frac{∂ ρcT}{∂ t} + ∇ ⋅ (F(α, ρcT, t)) = 0$

where

  • $F(α, ρcT, t) = -α ∇ρcT$ is the second-order flux

with boundary conditions

  • Fixed temperature $T_{surface}$ at $z_{min}$ (non-zero Dirichlet)
  • No thermal flux at $z_{min}$ (zero Neumann)

Solving these equations is broken down into the following steps:

  1. Preliminary configuration
  2. PDEs
  3. Space discretization
  4. Time discretization / solver
  5. Solver hooks / callbacks
  6. Solve
  7. Post-processing

Preliminary configuration

Loading code

First, we'll load our pre-requisites:

  • load external packages:
using MPI
using OrderedCollections
using Plots
using StaticArrays
using OrdinaryDiffEq
using DiffEqBase
  • load CLIMAParameters and set up to use it:
using CLIMAParameters
struct EarthParameterSet <: AbstractEarthParameterSet end
const param_set = EarthParameterSet()
Main.ex-heat_equation.EarthParameterSet()
  • load necessary ClimateMachine modules:
using ClimateMachine
using ClimateMachine.Mesh.Topologies
using ClimateMachine.Mesh.Grids
using ClimateMachine.DGMethods
using ClimateMachine.DGMethods.NumericalFluxes
using ClimateMachine.BalanceLaws:
    BalanceLaw, Prognostic, Auxiliary, Gradient, GradientFlux

using ClimateMachine.Mesh.Geometry: LocalGeometry
using ClimateMachine.MPIStateArrays
using ClimateMachine.GenericCallbacks
using ClimateMachine.ODESolvers
using ClimateMachine.VariableTemplates
using ClimateMachine.SingleStackUtils
  • import necessary ClimateMachine modules: (importing enables us to

provide implementations of these structs/methods)

import ClimateMachine.BalanceLaws:
    vars_state,
    source!,
    flux_second_order!,
    flux_first_order!,
    compute_gradient_argument!,
    compute_gradient_flux!,
    nodal_update_auxiliary_state!,
    nodal_init_state_auxiliary!,
    init_state_prognostic!,
    boundary_state!

import ClimateMachine.DGMethods: calculate_dt

Initialization

Define the float type (Float64 or Float32)

FT = Float64;

Initialize ClimateMachine for CPU.

ClimateMachine.init(; disable_gpu = true);

const clima_dir = dirname(dirname(pathof(ClimateMachine)));

Load some helper functions for plotting

include(joinpath(clima_dir, "docs", "plothelpers.jl"));

Define the set of Partial Differential Equations (PDEs)

Define the model

Model parameters can be stored in the particular BalanceLaw, in this case, a HeatModel:

Base.@kwdef struct HeatModel{FT, APS} <: BalanceLaw
    "Parameters"
    param_set::APS
    "Heat capacity"
    ρc::FT = 1
    "Thermal diffusivity"
    α::FT = 0.01
    "Initial conditions for temperature"
    initialT::FT = 295.15
    "Bottom boundary value for temperature (Dirichlet boundary conditions)"
    T_bottom::FT = 300.0
    "Top flux (α∇ρcT) at top boundary (Neumann boundary conditions)"
    flux_top::FT = 0.0
end

Create an instance of the HeatModel:

m = HeatModel{FT, typeof(param_set)}(; param_set = param_set);

This model dictates the flow control, using Dynamic Multiple Dispatch, for which kernels are executed.

Define the variables

All of the methods defined in this section were imported in Loading code to let us provide implementations for our HeatModel as they will be used by the solver.

Specify auxiliary variables for HeatModel

vars_state(::HeatModel, ::Auxiliary, FT) = @vars(z::FT, T::FT);

Specify prognostic variables, the variables solved for in the PDEs, for HeatModel

vars_state(::HeatModel, ::Prognostic, FT) = @vars(ρcT::FT);

Specify state variables whose gradients are needed for HeatModel

vars_state(::HeatModel, ::Gradient, FT) = @vars(ρcT::FT);

Specify gradient variables for HeatModel

vars_state(::HeatModel, ::GradientFlux, FT) = @vars(α∇ρcT::SVector{3, FT});

Define the compute kernels

Specify the initial values in aux::Vars, which are available in init_state_prognostic!. Note that

  • this method is only called at t=0
  • aux.z and aux.T are available here because we've specified z and T

in vars_state given Auxiliary in vars_state

function nodal_init_state_auxiliary!(
    m::HeatModel,
    aux::Vars,
    tmp::Vars,
    geom::LocalGeometry,
)
    aux.z = geom.coord[3]
    aux.T = m.initialT
end;

Specify the initial values in state::Vars. Note that

  • this method is only called at t=0
  • state.ρcT is available here because we've specified ρcT in

vars_state given Prognostic

function init_state_prognostic!(
    m::HeatModel,
    state::Vars,
    aux::Vars,
    coords,
    t::Real,
)
    state.ρcT = m.ρc * aux.T
end;

The remaining methods, defined in this section, are called at every time-step in the solver by the BalanceLaw framework.

Compute/update all auxiliary variables at each node. Note that

  • aux.T is available here because we've specified T in

vars_state given Auxiliary

function nodal_update_auxiliary_state!(
    m::HeatModel,
    state::Vars,
    aux::Vars,
    t::Real,
)
    aux.T = state.ρcT / m.ρc
end;

Since we have second-order fluxes, we must tell ClimateMachine to compute the gradient of ρcT. Here, we specify how ρcT is computed. Note that

  • transform.ρcT is available here because we've specified ρcT in

vars_state given Gradient

function compute_gradient_argument!(
    m::HeatModel,
    transform::Vars,
    state::Vars,
    aux::Vars,
    t::Real,
)
    transform.ρcT = state.ρcT
end;

Specify where in diffusive::Vars to store the computed gradient from compute_gradient_argument!. Note that:

  • diffusive.α∇ρcT is available here because we've specified α∇ρcT in

vars_state given Gradient

  • ∇transform.ρcT is available here because we've specified ρcT in

vars_state given Gradient

function compute_gradient_flux!(
    m::HeatModel,
    diffusive::Vars,
    ∇transform::Grad,
    state::Vars,
    aux::Vars,
    t::Real,
)
    diffusive.α∇ρcT = -m.α * ∇transform.ρcT
end;

We have no sources, nor non-diffusive fluxes.

function source!(m::HeatModel, _...) end;
function flux_first_order!(m::HeatModel, _...) end;

Compute diffusive flux ($F(α, ρcT, t) = -α ∇ρcT$ in the original PDE). Note that:

  • diffusive.α∇ρcT is available here because we've specified α∇ρcT in

vars_state given GradientFlux

function flux_second_order!(
    m::HeatModel,
    flux::Grad,
    state::Vars,
    diffusive::Vars,
    hyperdiffusive::Vars,
    aux::Vars,
    t::Real,
)
    flux.ρcT += diffusive.α∇ρcT
end;

Boundary conditions

Second-order terms in our equations, $∇⋅(F)$ where $F = -α∇ρcT$, are internally reformulated to first-order unknowns. Boundary conditions must be specified for all unknowns, both first-order and second-order unknowns which have been reformulated.

The boundary conditions for ρcT (first order unknown)

function boundary_state!(
    nf,
    m::HeatModel,
    state⁺::Vars,
    aux⁺::Vars,
    n⁻,
    state⁻::Vars,
    aux⁻::Vars,
    bctype,
    t,
    _...,
)
    # Apply Dirichlet BCs
    if bctype == 1 # At bottom
        state⁺.ρcT = m.ρc * m.T_bottom
    elseif bctype == 2 # At top
        nothing
    end
end;

The boundary conditions for ρcT are specified here for second-order unknowns

function boundary_state!(
    nf,
    m::HeatModel,
    state⁺::Vars,
    diff⁺::Vars,
    aux⁺::Vars,
    n⁻,
    state⁻::Vars,
    diff⁻::Vars,
    aux⁻::Vars,
    bctype,
    t,
    _...,
)
    # Apply Neumann BCs
    if bctype == 1 # At bottom
        nothing
    elseif bctype == 2 # At top
        diff⁺.α∇ρcT = n⁻ * m.flux_top
    end
end;

Spatial discretization

Prescribe polynomial order of basis functions in finite elements

N_poly = 5;

Specify the number of vertical elements

nelem_vert = 10;

Specify the domain height

zmax = FT(1);

Establish a ClimateMachine single stack configuration

driver_config = ClimateMachine.SingleStackConfiguration(
    "HeatEquation",
    N_poly,
    nelem_vert,
    zmax,
    param_set,
    m,
    numerical_flux_first_order = CentralNumericalFluxFirstOrder(),
);
┌ Info: Model composition
│     param_set = Main.ex-heat_equation.EarthParameterSet()
│     ρc = 1.0
│     α = 0.01
│     initialT = 295.15
│     T_bottom = 300.0
└     flux_top = 0.0
┌ Info: Establishing single stack configuration for HeatEquation
│     precision        = Float64
│     polynomial order = 5
│     domain_min       = 0.00 m x0.00 m x0.00 m
│     domain_max       = 1.00 m x1.00 m x1.00 m
│     #vert elems      = 10
│     MPI ranks        = 1
│     min(Δ_horz)      = 0.12 m
└     min(Δ_vert)      = 0.01 m

Time discretization / solver

Specify simulation time (SI units)

t0 = FT(0)
timeend = FT(40)
40.0

In this section, we initialize the state vector and allocate memory for the solution in space (dg has the model m, which describes the PDEs as well as the function used for initialization). SolverConfiguration initializes the ODE solver, by default an explicit Low-Storage Runge-Kutta method. In this tutorial, we prescribe an option for an implicit Kvaerno3 method.

First, let's define how the time-step is computed, based on the Fourier number (i.e., diffusive Courant number) is defined. Because the HeatModel is a custom model, we must define how both are computed. First, we must define our own implementation of DGMethods.calculate_dt, (which we imported):

function calculate_dt(dg, model::HeatModel, Q, Courant_number, t, direction)
    Δt = one(eltype(Q))
    CFL = DGMethods.courant(diffusive_courant, dg, model, Q, Δt, t, direction)
    return Courant_number / CFL
end
calculate_dt (generic function with 5 methods)

Next, we'll define our implementation of diffusive_courant:

function diffusive_courant(
    m::HeatModel,
    state::Vars,
    aux::Vars,
    diffusive::Vars,
    Δx,
    Δt,
    t,
    direction,
)
    return Δt * m.α / (Δx * Δx)
end
diffusive_courant (generic function with 1 method)

Finally, we initialize the state vector and solver configuration based on the given Fourier number. Note that, we can use a much larger Fourier number for implicit solvers as compared to explicit solvers.

use_implicit_solver = false
if use_implicit_solver
    given_Fourier = FT(30)

    solver_config = ClimateMachine.SolverConfiguration(
        t0,
        timeend,
        driver_config;
        ode_solver_type = ImplicitSolverType(OrdinaryDiffEq.Kvaerno3(
            autodiff = false,
            linsolve = LinSolveGMRES(),
        )),
        Courant_number = given_Fourier,
        CFL_direction = VerticalDirection(),
    )
else
    given_Fourier = FT(0.7)

    solver_config = ClimateMachine.SolverConfiguration(
        t0,
        timeend,
        driver_config;
        Courant_number = given_Fourier,
        CFL_direction = VerticalDirection(),
    )
end;


grid = solver_config.dg.grid;
Q = solver_config.Q;
aux = solver_config.dg.state_auxiliary;
[ Info: Initializing HeatEquation

Inspect the initial conditions

Let's export a plot of the initial state

output_dir = @__DIR__;

mkpath(output_dir);

z_scale = 100; # convert from meters to cm
z_key = "z";
z_label = "z [cm]";
z = get_z(grid, z_scale);

Create an array to store the solution:

all_data = Dict[dict_of_nodal_states(solver_config, [z_key])]  # store initial condition at ``t=0``
time_data = FT[0]                                      # store time data

export_plot(
    z,
    all_data,
    ("ρcT",),
    joinpath(output_dir, "initial_condition.png");
    xlabel = "ρcT",
    ylabel = z_label,
    time_data = time_data,
);

It matches what we have in init_state_prognostic!(m::HeatModel, ...), so let's continue.

Solver hooks / callbacks

Define the number of outputs from t0 to timeend

const n_outputs = 5;

This equates to exports every ceil(Int, timeend/n_outputs) time-step:

const every_x_simulation_time = ceil(Int, timeend / n_outputs);

The ClimateMachine's time-steppers provide hooks, or callbacks, which allow users to inject code to be executed at specified intervals. In this callback, a dictionary of prognostic and auxiliary states are appended to all_data for time the callback is executed. In addition, time is collected and appended to time_data.

callback = GenericCallbacks.EveryXSimulationTime(every_x_simulation_time) do
    push!(all_data, dict_of_nodal_states(solver_config, [z_key]))
    push!(time_data, gettime(solver_config.solver))
    nothing
end;

Solve

This is the main ClimateMachine solver invocation. While users do not have access to the time-stepping loop, code may be injected via user_callbacks, which is a Tuple of callbacks in GenericCallbacks.

ClimateMachine.invoke!(solver_config; user_callbacks = (callback,));
┌ Info: Starting HeatEquation
│     dt              = 9.65950e-03
│     timeend         =    40.00
│     number of steps = 4141
└     norm(Q)         = 2.9515000000000049e+02
┌ Info: Finished
│     norm(Q)            = 2.9853556724952745e+02
│     norm(Q) / norm(Q₀) = 1.0114706666085955e+00
└     norm(Q) - norm(Q₀) = 3.3855672495269573e+00

Append result at the end of the last time step:

push!(all_data, dict_of_nodal_states(solver_config, [z_key]));
push!(time_data, gettime(solver_config.solver));

Post-processing

Our solution is stored in the array of dictionaries all_data whose keys are the output interval. The next level keys are the variable names, and the values are the values along the grid:

To get T at $t=0$, we can use T_at_t_0 = all_data[1]["T"][:]

@show keys(all_data[1])
Base.KeySet for a Dict{String,Array{Float64,1}} with 2 entries. Keys:
  "T"
  "ρcT"

Let's plot the solution:

export_plot(
    z,
    all_data,
    ("ρcT",),
    joinpath(output_dir, "solution_vs_time.png");
    xlabel = "ρcT",
    ylabel = z_label,
    time_data = time_data,
);

The results look as we would expect: a fixed temperature at the bottom is resulting in heat flux that propagates up the domain. To run this file, and inspect the solution in all_data, include this tutorial in the Julia REPL with:

include(joinpath("tutorials", "Land", "Heat", "heat_equation.jl"))

This page was generated using Literate.jl.