Replicating the Community Ice Sheet Model v2.1 Halfar Dome Benchmark with Decapodes

The Decapodes framework takes high-level representations of physics equations and automatically generates solvers.

We do so by translating equations from vector calculus notation to the "discrete exterior calculus" (DEC). This process is roughly about recognizing whether physical quantities represent scalar or vector quantities, and recognizing whether differential operators represent gradient, divergence, and so on.

In this benchmark, we will implement the "small slope approximation" of glacial dynamics used by P. Halfar in his 1981 work "On the dynamics of the ice sheets" by taking his original formulation, translating it into the DEC, then providing a mesh and initial conditions.

The initial conditions used here are exactly those considered by W. H. Lipscomb et al. in "Description And Evaluation of the Community Ice Sheet Model (CISM) v2.1" (2019).

In [ ]:
# AlgebraicJulia Dependencies
using Catlab
using Catlab.Graphics
using CombinatorialSpaces
using Decapodes

# External Dependencies
using Pkg
using MLStyle
using MultiScaleArrays
using LinearAlgebra
using OrdinaryDiffEq
using JLD2
using SparseArrays
using Statistics
using GLMakie
using BenchmarkTools
using GeometryBasics: Point2
Point2D = Point2{Float64};

Halfar Equation 2

We will translate Halfar's equation into the DEC below. Although the equation given by Halfar is dense, this notation does not allow you to see which operators represent divergence, which components represent diffusivity constants, and so on.

In the DEC, gradients are generalized by the exterior derivative "d". Given scalar-data, d gives the slope along edges in our mesh. Similarly, the operator (⋆, d, ⋆) generalizes divergence.

In Halfar's equation there is a term corresponding to the magnitude of the slope, but it is not clear where this quantity is to be defined. Is it a scalar-like quantity, or a vector like quantity? In the DEC translation, we take the gradient of h, d(h), and use the "sharp" operator to define it on points in the domain, where we then take its magnitude, then perform an averaging to define it along edges.

The front-most term defines a quantity - depending on the strain of the ice - that controls the rate at which ice diffuses. Since this computation is rather separate from the rest of the computations involving our differential operators, we will call it "Gamma" here, and define it in a later component Decapode.

In [ ]:
####################
# Define the model #
####################

# Equation 2 from Halfar, P. ON THE DYNAMICS OF THE ICE SHEETS. (1981),
# translated into the exterior calculus.
halfar_eq2 = @decapode begin
  h::Form0
  Γ::Form1
  n::Constant

   == ∂ₜ(h)
   == (, d, )(Γ * d(h) * avg₀₁(mag((d(h)))^(n-1)) * avg₀₁(h^(n+2)))
end

to_graphviz(halfar_eq2)
G n1 h:Ω₀ n4 ḣ:Ω• n1->n4 ∂ₜ n7 •1:Ω• n1->n7 d n12 •6:Ω• n1->n12 d n24 Ω₀×Ω• n1->n24 π₁ n2 Γ:Ω₁ n25 Ω₁×Ω• n2->n25 π₁ n3 n:ΩC n22 ΩC×ΩL n3->n22 π₁ n28 Σ1 n3->n28 n21 n4->n21 n5 mult_1:Ω• n27 Ω•×Ω• n5->n27 π₁ n6 mult_2:Ω• n6->n4 ⋆⋅d⋅⋆ n7->n25 π₂ n8 •2:Ω• n26 Ω•×Ω• n8->n26 π₂ n9 •3:Ω• n9->n8 avg₀₁ n10 •4:Ω• n23 Ω•×Ω• n10->n23 π₁ n11 •5:Ω• n11->n10 mag n12->n11 n13 •7:Ω• n13->n23 π₂ n14 1:ΩL n14->n22 π₂ n15 •8:Ω• n15->n27 π₂ n16 •9:Ω• n16->n15 avg₀₁ n17 2:ΩL n17->n28 n18 sum_1:Ω• n18->n24 π₂ n19 mult_3:Ω• n19->n26 π₁ n20 n20->n1 n20->n2 n20->n3 n22->n13 - n23->n9 ^ n24->n16 ^ n25->n19 * n26->n5 * n27->n6 * n28->n18 +

Glen's Law

This is "Glen's Flow Law." It states that the strain rate of a sheet of ice can be related to applied stress via a power law. Below, we encode the formulation as it is usually given in the literature, depending explicitly on the gravitational constant, g, for example.

In [ ]:
# Equation 1 from Glen, J. W. THE FLOW LAW OF ICE: A discussion of the
# assumptions made in glacier theory, their experimental foundations and
# consequences. (1958)
glens_law = @decapode begin
  Γ::Form1
  (A,ρ,g,n)::Constant
  
  Γ == (2/(n+2))*A*(ρ*g)^n
end

to_graphviz(glens_law)
G n1 Γ:Ω₁ n2 A:ΩC n17 Ω•×ΩC n2->n17 π₂ n3 ρ:ΩC n15 ΩC×ΩC n3->n15 π₁ n4 g:ΩC n4->n15 π₂ n5 n:ΩC n16 Ω•×ΩC n5->n16 π₂ n19 Σ1 n5->n19 n6 •1:Ω• n6->n17 π₁ n7 2:ΩL n14 ΩL×Ω• n7->n14 π₁ n7->n19 n8 sum_1:Ω• n8->n14 π₂ n9 •2:Ω• n18 Ω•×Ω• n9->n18 π₂ n10 •3:Ω• n10->n16 π₁ n11 mult_1:Ω• n11->n18 π₁ n12 n12->n2 n12->n3 n12->n4 n12->n5 n13 n14->n6 / n15->n10 * n16->n9 ^ n17->n11 * n18->n1 * n19->n8 +

We now need some way to compose these physics equations together. Since this physics is rather small, and there are no naming conflicts of physical quantities, this composition is also rather simple.

In [ ]:
#####################
# Compose the model #
#####################

ice_dynamics_composition_diagram = @relation () begin
  dynamics(Γ,n)
  stress(Γ,n)
end
to_graphviz(ice_dynamics_composition_diagram, box_labels=:name, junction_labels=:variable, prog="circo")
G n1 dynamics n3 Γ n1--n3 n4 n n1--n4 n2 stress n2--n3 n2--n4
In [ ]:
# Plug in our Decapodes to the composition pattern.
ice_dynamics_cospan = oapply(ice_dynamics_composition_diagram,
  [Open(halfar_eq2, [:Γ,:n]),
   Open(glens_law, [:Γ,:n])])

ice_dynamics = apex(ice_dynamics_cospan)
to_graphviz(ice_dynamics)
G n1 dynamics_h:Ω₀ n4 dynamics_ḣ:Ω• n1->n4 ∂ₜ n7 dynamics_•1:Ω• n1->n7 d n12 dynamics_•6:Ω• n1->n12 d n33 Ω₀×Ω• n1->n33 π₁ n2 Γ:Ω₁ n34 Ω₁×Ω• n2->n34 π₁ n3 n:ΩC n31 ΩC×ΩL n3->n31 π₁ n39 Ω•×ΩC n3->n39 π₂ n42 Σ1 n3->n42 n43 Σ2 n3->n43 n30 n4->n30 n5 dynamics_mult_1:Ω• n36 Ω•×Ω• n5->n36 π₁ n6 dynamics_mult_2:Ω• n6->n4 ⋆⋅d⋅⋆ n7->n34 π₂ n8 dynamics_•2:Ω• n35 Ω•×Ω• n8->n35 π₂ n9 dynamics_•3:Ω• n9->n8 avg₀₁ n10 dynamics_•4:Ω• n32 Ω•×Ω• n10->n32 π₁ n11 dynamics_•5:Ω• n11->n10 mag n12->n11 n13 dynamics_•7:Ω• n13->n32 π₂ n14 1:ΩL n14->n31 π₂ n15 dynamics_•8:Ω• n15->n36 π₂ n16 dynamics_•9:Ω• n16->n15 avg₀₁ n17 2:ΩL n17->n42 n18 dynamics_sum_1:Ω• n18->n33 π₂ n19 dynamics_mult_3:Ω• n19->n35 π₁ n20 stress_A:ΩC n40 Ω•×ΩC n20->n40 π₂ n21 stress_ρ:ΩC n38 ΩC×ΩC n21->n38 π₁ n22 stress_g:ΩC n22->n38 π₂ n23 stress_•1:Ω• n23->n40 π₁ n24 2:ΩL n37 ΩL×Ω• n24->n37 π₁ n24->n43 n25 stress_sum_1:Ω• n25->n37 π₂ n26 stress_•2:Ω• n41 Ω•×Ω• n26->n41 π₂ n27 stress_•3:Ω• n27->n39 π₁ n28 stress_mult_1:Ω• n28->n41 π₁ n29 n29->n1 n29->n3 n29->n20 n29->n21 n29->n22 n31->n13 - n32->n9 ^ n33->n16 ^ n34->n19 * n35->n5 * n36->n6 * n37->n23 / n38->n27 * n39->n26 ^ n40->n28 * n41->n2 * n42->n18 + n43->n25 +

We have a representation of our composed physics. Now, we need to specify that these dynamics occur in 2 dimensions.

In [ ]:
#######################
# Provide a Semantics #
#######################

# Interpret this multiphysics diagram in the 2D exterior calculus.

ice_dynamics2D = expand_operators(ice_dynamics)
infer_types!(ice_dynamics2D)
resolve_overloads!(ice_dynamics2D)
to_graphviz(ice_dynamics2D)
G n1 dynamics_h:Ω₀ n4 dynamics_ḣ:Ω₀ n1->n4 ∂ₜ n7 dynamics_•1:Ω₁ n1->n7 d₀ n12 dynamics_•6:Ω₁ n1->n12 d₀ n35 Ω₀×ΩC n1->n35 π₁ n2 Γ:Ω₁ n36 Ω₁×Ω₁ n2->n36 π₁ n3 n:ΩC n33 ΩC×ΩL n3->n33 π₁ n41 Ω•×ΩC n3->n41 π₂ n44 Σ1 n3->n44 n45 Σ2 n3->n45 n32 n4->n32 n5 dynamics_mult_1:Ω• n38 Ω•×Ω• n5->n38 π₁ n6 dynamics_mult_2:Ω₁ n29 •_8_1:Ω̃₁ n6->n29 ⋆₁ n7->n36 π₂ n8 dynamics_•2:Ω• n37 Ω₁×Ω• n8->n37 π₂ n9 dynamics_•3:Ω• n9->n8 avg₀₁ n10 dynamics_•4:Ω• n34 Ω•×Ω• n10->n34 π₁ n11 dynamics_•5:Ω• n11->n10 mag n12->n11 n13 dynamics_•7:Ω• n13->n34 π₂ n14 1:ΩL n14->n33 π₂ n15 dynamics_•8:Ω• n15->n38 π₂ n16 dynamics_•9:Ω• n16->n15 avg₀₁ n17 2:ΩL n17->n44 n18 dynamics_sum_1:ΩC n18->n35 π₂ n19 dynamics_mult_3:Ω₁ n19->n37 π₁ n20 stress_A:ΩC n42 Ω•×ΩC n20->n42 π₂ n21 stress_ρ:ΩC n40 ΩC×ΩC n21->n40 π₁ n22 stress_g:ΩC n22->n40 π₂ n23 stress_•1:Ω• n23->n42 π₁ n24 2:ΩL n39 ΩL×ΩC n24->n39 π₁ n24->n45 n25 stress_sum_1:ΩC n25->n39 π₂ n26 stress_•2:Ω• n43 Ω•×Ω• n26->n43 π₂ n27 stress_•3:Ω• n27->n41 π₁ n28 stress_mult_1:Ω• n28->n43 π₁ n30 •_8_2:Ω̃₂ n29->n30 dual_d₁ n30->n4 ⋆₀⁻¹ n31 n31->n1 n31->n3 n31->n20 n31->n21 n31->n22 n33->n13 - n34->n9 ^ n35->n16 ^ n36->n19 * n37->n5 * n38->n6 * n39->n23 / n40->n27 * n41->n26 ^ n42->n28 * n43->n2 * n44->n18 + n45->n25 +

We are done encoding our dynamics. Now, we need to provide a mesh, initial data to use for our quantities, and what functions to use for our differential operators.

Our mesh library, CombinatorialSpaces, can interpret arbitrary .OBJ files to run our dynamics on. Here, we use a script that generates a triangulated grid of the resolution used in the CISM benchmark. Note though that the "resolution" of a triangulated and non-triangulated grid is difficult to directly compare.

In [ ]:
###################
# Define the mesh #
###################

include("../../grid_meshes.jl")
s′ = triangulated_grid(60_000,100_000,2_000,2_000,Point3D)
s = EmbeddedDeltaDualComplex2D{Bool, Float64, Point3D}(s′)
subdivide_duals!(s, Barycenter())
 = mean(p -> p[1], point(s))
 = mean(p -> p[2], point(s))

wf = wireframe(s)
save("ice_mesh.png", wf)

Wireframe of the Domain

In [ ]:
########################################################
# Define constants, parameters, and initial conditions #
########################################################

# These are the initial conditions to the Halfar Dome test case that the
# Community Ice Sheet Model uses.
R₀ = 60_000 * sqrt(0.125)
H = 2_000 * sqrt(0.125)

n = 3
g = 9.8101
ρ = 910
alpha = 1/9
beta = 1/18
flwa = 1e-16
A = fill(1e-16, ne(s))

Gamma = 2.0/(n+2) * flwa * (ρ * g)^n
t0 = (beta/Gamma) * (7.0/4.0)^3 * (R₀^4 / H^7)

# This is the analytic solution for comparison.
# It is ported over from the CISM code for comparison's sake,
# and we will use it to set initial conditions.
function height_at_p(x,y,t)
  tr = (t + t0) / t0
  r = sqrt((x - )^2 + (y - )^2)
  r = r/R₀
  inside = max(0.0, 1.0 - (r / tr^beta)^((n+1.0) / n))
  H * inside^(n / (2*n + 1)) / tr^alpha
end

# Set the initial conditions for ice sheet height:
# Ice height is a primal 0-form. i.e. valued at vertices.
h₀ = map(x -> height_at_p(x[1], x[2], 0), point(s′))
fig = mesh(s′, color=h₀, colormap=:jet)
save("ice_initial_conditions.png", fig)

Initial Conditions

In [ ]:
# Store these values to be passed to the solver.
u₀ = construct(PhysicsState, [VectorForm(h₀)], Float64[], [:dynamics_h])
constants_and_parameters = (
  n = n,
  stress_ρ = ρ,
  stress_g = g,
  stress_A = A)
(n = 3, stress_ρ = 910, stress_g = 9.8101, stress_A = [1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16  …  1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16, 1.0e-16])

We provide here the mapping from symbols to differential operators. As more of the differential operators of the DEC are implemented, they are upstreamed to the Decapodes and CombinatorialSpaces libraries. Of course, users can also provide their own implementations of these operators and others as they see fit.

In [ ]:
#############################################
# Define how symbols map to Julia functions #
#############################################

function generate(sd, my_symbol; hodge=GeometricHodge())
  # We pre-allocate matrices that encode differential operators.
  ♯_m = ♯_mat(sd, AltPPSharp())
  # This averaging operator is scheduled to be upstreamed to the main repository.
  # We will leave it here for the time being.
  I = Vector{Int64}()
  J = Vector{Int64}()
  V = Vector{Float64}()
  for e in 1:ne(sd)
      append!(J, [sd[e,:∂v0],sd[e,:∂v1]])
      append!(I, [e,e])
      append!(V, [0.5, 0.5])
  end
  avg_mat = sparse(I,J,V)
  op = @match my_symbol begin
    : => x -> begin
      ♯_m * x
  end
    :mag => x -> begin
      norm.(x)
    end
    :avg₀₁ => x -> begin
      avg_mat * x
    end
    :^ => (x,y) -> x .^ y
    :* => (x,y) -> x .* y
    :abs => x -> abs.(x)
    :show => x -> begin
      println(x)
      x
    end
    x => error("Unmatched operator $my_symbol")
  end
  return (args...) -> op(args...)
end
generate (generic function with 1 method)

The gensim function takes our high-level representation of the physics equations and produces compiled simulation code. It performs optimizations such as allocating memory for intermediate variables, and so on.

In [ ]:
#######################
# Generate simulation #
#######################

sim = eval(gensim(ice_dynamics2D))
fₘ = sim(s, generate)
(::var"#f#57"{Vector{Float64}, Vector{Float64}, Vector{Float64}, Vector{Float64}, Vector{Float64}, Vector{Float64}, var"#47#55"{var"#43#51"}, var"#47#55"{var"#42#50"{SparseMatrixCSC{Float64, Int64}}}, var"#47#55"{var"#41#49"}, var"#47#55"{var"#40#48"{SparseMatrixCSC{Point3{Float64}, Int64}}}, Diagonal{Float64, Vector{Float64}}, SparseMatrixCSC{Int64, Int64}, SparseMatrixCSC{Float64, Int64}, SparseMatrixCSC{Int64, Int64}}) (generic function with 1 method)

Julia is a "Just-In-Time" compiled language. That means that functions are compiled the first time they are called, and later calls to those functions skip this step. To get a feel for just how fast this simulation is, we will run the dynamics twice, once for a very short timespan to trigger pre-compilation, and then again for the actual dynamics.

In [ ]:
##########################
# Pre-compile simulation #
##########################

# Julia will pre-compile the generated simulation the first time it is run.
@info("Precompiling Solver")
# We run for a short timespan to pre-compile.
prob = ODEProblem(fₘ, u₀, (0, 1e-8), constants_and_parameters)
soln = solve(prob, Tsit5())
soln.retcode != :Unstable || error("Solver was not stable")
┌ Info: Precompiling Solver
└ @ Main c:\Users\lukel\Prgming\Decapodes.jl\examples\climate\cism.ipynb:6
true
In [ ]:
##################
# Run simulation #
##################
tₑ = 200

# This next run should be fast.
@info("Solving")
prob = ODEProblem(fₘ, u₀, (0, tₑ), constants_and_parameters)
soln = solve(prob, Tsit5())
@show soln.retcode
@info("Done")
┌ Info: Solving
└ @ Main c:\Users\lukel\Prgming\Decapodes.jl\examples\climate\cism.ipynb:7
soln.retcode = SciMLBase.ReturnCode.Success
┌ Info: Done
└ @ Main c:\Users\lukel\Prgming\Decapodes.jl\examples\climate\cism.ipynb:11

We can benchmark the compiled simulation with @btime. This macro runs many samples of the simulation function so we get an accurate estimate of the simulation time. It takes only 67 milliseconds to run the entire simulation. These results are obtained on a 2016 Windows Surfacebook laptop.

In [ ]:
#######################
# Time the simulation #
#######################

@btime soln = solve(prob, Tsit5())
  67.748 ms (6386 allocations: 49.76 MiB)
retcode: Success
Interpolation: specialized 4th order "free" interpolation
t: 28-element Vector{Float64}:
   0.0
   0.00048325434495301707
   0.005315797794483188
   0.05364123228978489
   0.2980177635503396
   0.806873048757719
   1.5627116382223352
   2.5909821472142998
   4.001412545681896
   5.873535833958046
   ⋮
  64.35151328958564
  76.48000871456102
  90.35980456498348
 105.37887525777296
 123.14249943053203
 143.04823491382817
 166.01304376498248
 191.95464911095308
 200.0
u: 28-element Vector{PhysicsState{VectorForm{Float64}, Float64}}:
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 ⋮
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
 PhysicsState{VectorForm{Float64}, Float64}(VectorForm{Float64}[VectorForm{Float64}([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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])], Float64[], [1581], [:dynamics_h])
In [ ]:
# Save the solution information to a file.
@save "ice_dynamics2D.jld2" soln

We recall that these dynamics are of the "shallow slope" and "shallow ice" approximations. So, at the edge of our parabolic dome of ice, we expect increased error as the slope increases. On the interior of the dome, we expect the dynamics to match more closely that given by the analytic model.

Halfar Small Ice Approximation Quote

In [ ]:
# Plot the final conditions
function plot_final_conditions()
  fig = Figure()
  ax = Axis(fig[1,1],
    title="Modeled thickness (m) at time 200.0",
    aspect=0.6)
  msh = mesh!(ax, s′, color=findnode(soln(200.0), :dynamics_h), colormap=:jet)
  Colorbar(fig[1,2], msh)
  fig
end
fig = plot_final_conditions()
save("ice_numeric_solution.png", fig)

Numerical Solution

In [ ]:
# Plot the final conditions according to the analytic solution.
function plot_analytic()
  hₐ = map(x -> height_at_p(x[1], x[2], 200.0), point(s′))
  fig = Figure()
  ax = Axis(fig[1,1],
    title="Analytic thickness (m) at time 200.0",
    aspect=0.6)
  msh = mesh!(ax, s′, color=hₐ, colormap=:jet)
  Colorbar(fig[1,2], msh)
  fig
end
fig = plot_analytic()
save("ice_analytic_solution.png", fig)

Analytic Solution

In [ ]:
# Plot the error.
function plot_error()
  hₐ = map(x -> height_at_p(x[1], x[2], 200.0), point(s′))
  h_diff = findnode(soln(tₑ), :dynamics_h) - hₐ
  extrema(h_diff)
  fig = Figure()
  ax = Axis(fig[1,1],
    title="Modeled thickness - Analytic thickness at time 200.0",
    aspect=0.6)
  msh = mesh!(ax, s′, color=h_diff, colormap=:jet)
  Colorbar(fig[1,2], msh)
  fig
end
fig = plot_error()
save("ice_error.png", fig)

Numeric Solution - Analytic Solution

We compute below that the maximum absolute error is approximately 89 meters. We observe that this error occurs exactly on the edge of the dome, which we expect given that this is where the "shallow slope approximation" breaks down, and the updates to our physical quantities should become more unstable. This pattern likewise occurs in the CISM benchmarks.

In [ ]:
# Compute max absolute error:
hₐ = map(x -> height_at_p(x[1], x[2], 200.0), point(s′))
h_diff = findnode(soln(tₑ), :dynamics_h) - hₐ
maximum(abs.(h_diff))
89.25883061368137

We compute RMSE as well, excluding where the ice distribution is 0 in the analytic solution. We compute about 10.4 RMSE using this technique. The CISM benchmark reports 6.43 and 9.06 RMSE for their two solver implementations.

In [ ]:
# Compute RMSE
hₐ = map(x -> height_at_p(x[1], x[2], 200.0), point(s′))
nonzeros = findall(!=(0), hₐ)
h_diff = findnode(soln(tₑ), :dynamics_h) - hₐ
rmse = sqrt(sum(map(x -> x*x, h_diff[nonzeros])) / length(nonzeros))
10.399805753564136
In [ ]:
# Create a gif
begin
  frames = 100
  fig, ax, ob = mesh(s′, color=findnode(soln(0), :dynamics_h), colormap=:jet, colorrange=extrema(findnode(soln(tₑ), :dynamics_h)))
  Colorbar(fig[1,2], ob)
  record(fig, "ice_dynamics_cism.gif", range(0.0, tₑ; length=frames); framerate = 30) do t
    ob.color = findnode(soln(t), :dynamics_h)
  end
end
"ice_dynamics_cism.gif"

Ice Dynamics

For comparison's sake, we paste the results produced by CISM below. We observe that the error likewise accumulates around the edge of the dome, with more accurate predictions on the interior. We note that our simulation produces slight over-estimates on the interior, but there are further strategies that one can employ to increase accuracy, such as tweaking the error tolerance of the solver, and so on.

Not that since the DEC is based on triangulated meshes, the "resolution" of the CISM benchmark and the Decapodes implementation cannot be directly compared. An advantage of the DEC is that we do not need to operate on uniform grids. For example, you could construct a mesh that is finer along the dome edge, where you need more resolution, and coarser as you are farther away from the reach of the ice.

CISM Results

We saw in this document how to create performant and accurate simulations in the Decapodes framework, and compared against the CISM library . Although we do not expect to be both more performant and accurate compared to every hand-crafted simulation, Decapodes makes up for this difference in terms of development time, flexibility, and composition. For example, the original implementation of the Decapodes shallow ice model took place over a couple of afternoons during a hackathon.

Since Decapodes targets high-level representations of physics, it is uniquely suited to incorporating knowledge from subject matter experts to increase simulation accuracy. This process does not require an ice dynamics expert to edit physics equations that have already been weaved into solver code.

Further improvements to the Decapodes library are made continuously. We are creating implementations of DEC operators that are constructed and execute faster. And we are in the beginning stages of 3D simulations using the DEC.