Skip to main content

Time-dependent data assimilation

Warning: the code demonstrated in this notebook is experimental. Use it at your own risk. We had to tweak solver choices and settings in order to make it work. If you try to build off of it and something breaks, please get in touch.

This notebook will demonstrate some more of the capabilities that icepack has for assimilating observational data. In a previous demo, we showed how to estimate a parameter in an ice model (the fluidity) from remote sensing observations. To do so, we had to specify:

  1. the loss functional, or how we measure the agreement of our computed state with observations
  2. the regularization functional, or how unusual or complex our guess for these parameters were, and
  3. the simulation, or what the physics is that relates the parameters and the observable fields.

In the previous demo, the governing physics was the momentum conservation equation of ice flow, which is time-independent. Here we'll look at how to use more involved simulations, including both mass and momentum conservation, that relate the unknown and the observable fields. The resulting simulation now depends on time. This is possible thanks to the adjoint capabilities in Firedrake and it looks pretty similar to the simpler time-dependent case.

Rather than try to estimate an unobservable parameter as we did in the previous demo, we'll focus here on estimating the value of an initial condition from measurements of the glacier at a later time. In principle, you can do joint estimation of both state and parameters at once; as far as the code is concerned, there's no distinction between the two. We've stuck to a pure state estimation problem here just to keep things simple.

Setup

We'll start from the MISMIP+ geometry and steady state from the previous notebooks. Computing the steady state of the MISMIP+ test case is expensive. Rather than do a cold start every time, we'll instead load up a previously-computed steady state from checkpoint files if they're available. (See the how-to guide on checkpointing.) If not, we'll do an initial spin-up for 3600 years using a cheaper degree-1 finite element basis and then a final spin-up using a degree-2 basis.

import firedrake
from firedrake import (
    exp,
    sqrt,
    inner,
    as_vector,
    grad,
    max_value,
    Constant,
    interpolate,
    dx,
)

Lx, Ly = 640e3, 80e3
ny = 20
nx = int(Lx / Ly) * ny
area = Constant(Lx * Ly)

mesh = firedrake.RectangleMesh(nx, ny, Lx, Ly, name="mesh")

Q2 = firedrake.FunctionSpace(mesh, "CG", 2)
V2 = firedrake.VectorFunctionSpace(mesh, "CG", 2)
def mismip_bed(mesh):
    x, y = firedrake.SpatialCoordinate(mesh)

    x_c = Constant(300e3)
    X = x / x_c

    B_0 = Constant(-150)
    B_2 = Constant(-728.8)
    B_4 = Constant(343.91)
    B_6 = Constant(-50.57)
    B_x = B_0 + B_2 * X ** 2 + B_4 * X ** 4 + B_6 * X ** 6

    f_c = Constant(4e3)
    d_c = Constant(500)
    w_c = Constant(24e3)

    B_y = d_c * (
        1 / (1 + exp(-2 * (y - Ly / 2 - w_c) / f_c)) +
        1 / (1 + exp(+2 * (y - Ly / 2 + w_c) / f_c))
    )

    z_deep = Constant(-720)

    return max_value(B_x + B_y, z_deep)
A = Constant(20)
C = Constant(1e-2)

We'll use the Schoof-type friction law from before rather than the Weertman sliding law.

from icepack.constants import (
    ice_density as ρ_I,
    water_density as ρ_W,
    gravity as g,
    weertman_sliding_law as m,
)


def friction(**kwargs):
    variables = ("velocity", "thickness", "surface", "friction")
    u, h, s, C = map(kwargs.get, variables)

    p_W = ρ_W * g * max_value(0, -(s - h))
    p_I = ρ_I * g * h
    N = max_value(0, p_I - p_W)
    τ_c = N / 2

    u_c = (τ_c / C) ** m
    u_b = sqrt(inner(u, u))

    return τ_c * (
        (u_c**(1 / m + 1) + u_b**(1 / m + 1))**(m / (m + 1)) - u_c
    )
a_0 = Constant(0.3)
import icepack
model = icepack.models.IceStream(friction=friction)
import tqdm


def run_simulation(solver, h, s, u, z_b, final_time, dt):
    h_in = Constant(100.0)
    a = interpolate(a_0, h.function_space())

    num_steps = int(final_time / dt)
    for step in tqdm.trange(num_steps):
        h = solver.prognostic_solve(
            dt,
            thickness=h,
            velocity=u,
            accumulation=a,
            thickness_inflow=h_in,
        )
        s = icepack.compute_surface(thickness=h, bed=z_b)

        u = solver.diagnostic_solve(
            velocity=u,
            thickness=h,
            surface=s,
            fluidity=A,
            friction=C,
        )

    return h, s, u
opts = {
    "dirichlet_ids": [1],
    "side_wall_ids": [3, 4],
    "diagnostic_solver_type": "petsc",
    "diagnostic_solver_parameters": {
        "snes_type": "newtontr",
        "ksp_type": "preonly",
        "pc_type": "lu",
        "pc_factor_mat_solver_type": "mumps",
    },
    "prognostic_solver_parameters": {
        "ksp_type": "gmres",
        "pc_type": "ilu",
    },
}

Load in the steady state of the system, computed with degree-1 elements, from checkpoint files if it exists. Recreate the steady state from a cold start if not.

import os

if os.path.exists("mismip-degree1.h5"):
    with firedrake.CheckpointFile("mismip-degree1.h5", "r") as chk:
        mesh = chk.load_mesh(name="mesh")

        h_1 = chk.load_function(mesh, name="thickness")
        s_1 = chk.load_function(mesh, name="surface")
        u_1 = chk.load_function(mesh, name="velocity")
        
        Q1 = h_1.function_space()
        V1 = u_1.function_space()
else:
    mesh = firedrake.RectangleMesh(nx, ny, Lx, Ly, name="mesh")
    Q1 = firedrake.FunctionSpace(mesh, "CG", 1)
    V1 = firedrake.VectorFunctionSpace(mesh, "CG", 1)

    z_b = interpolate(mismip_bed(mesh), Q1)
    h_0 = interpolate(Constant(100), Q1)
    s_0 = icepack.compute_surface(thickness=h_0, bed=z_b)

    flow_solver = icepack.solvers.FlowSolver(model, **opts)
    x = firedrake.SpatialCoordinate(mesh)[0]
    u_0 = flow_solver.diagnostic_solve(
        velocity=interpolate(as_vector((90 * x / Lx, 0)), V1),
        thickness=h_0,
        surface=s_0,
        fluidity=A,
        friction=C,
    )

    dt = 5.0
    final_time = 3600

    h_1, s_1, u_1 = run_simulation(
        flow_solver, h_0, s_0, u_0, z_b, final_time, dt
    )

    with firedrake.CheckpointFile("mismip-degree1.h5", "w") as chk:
        chk.save_mesh(mesh)
        chk.save_function(h_1, name="thickness")
        chk.save_function(s_1, name="surface")
        chk.save_function(u_1, name="velocity")
100%|██████████| 720/720 [02:24<00:00,  4.99it/s]

Load in the steady state computed with degree-2 elements from a file if it exists, or spin it up from the degree-1 solution if not.

flow_solver = icepack.solvers.FlowSolver(model, **opts)

if os.path.exists("mismip-degree2.h5"):
    with firedrake.CheckpointFile("mismip-degree2.h5", "r") as chk:
        mesh = chk.load_mesh(name="mesh")
        h = chk.load_function(mesh, name="thickness")
        s = chk.load_function(mesh, name="surface")
        u = chk.load_function(mesh, name="velocity")
        
        Q2 = h.function_space()
        V2 = u.function_space()
else:
    Q2 = firedrake.FunctionSpace(mesh, "CG", 2)
    V2 = firedrake.VectorFunctionSpace(mesh, "CG", 2)

    h = interpolate(h_1, Q2)
    s = interpolate(s_1, Q2)
    u = interpolate(u_1, V2)

    final_time = 3600
    dt = 4.0

    h, s, u = run_simulation(
        flow_solver, h, s, u, z_b, final_time, dt
    )

    with firedrake.CheckpointFile("mismip-degree2.h5", "w") as chk:
        chk.save_mesh(mesh)
        chk.save_function(h, name="thickness")
        chk.save_function(s, name="surface")
        chk.save_function(u, name="velocity")
        
z_b = interpolate(mismip_bed(mesh), Q2)
100%|██████████| 900/900 [17:45<00:00,  1.18s/it]

Simulation

For the inversion scenario, we'd like to make the system do something a little more interesting than just relax to steady state. To achieve that, we'll add a 1-year periodic oscillation to the accumulation rate. The only change in the core simulation loop is that now we're interpolating a new value to the accumulation rate at every step. Additionally, we're keeping the full time history of the system state in a list instead of just storing the final state.

import numpy as np
from numpy import pi as π

final_time = 25.0
dt = 1.0 / 24

hs = [h.copy(deepcopy=True)]
ss = [s.copy(deepcopy=True)]
us = [u.copy(deepcopy=True)]

h_in = Constant(100.0)
a = firedrake.Function(Q2)
δa = Constant(0.2)

num_steps = int(final_time / dt)
for step in tqdm.trange(num_steps):
    t = step * dt
    a.interpolate(a_0 + δa * firedrake.sin(2 * π * t))
    
    h = flow_solver.prognostic_solve(
        dt,
        thickness=h,
        velocity=u,
        accumulation=a,
        thickness_inflow=h_in,
    )
    s = icepack.compute_surface(thickness=h, bed=z_b)

    u = flow_solver.diagnostic_solve(
        velocity=u,
        thickness=h,
        surface=s,
        fluidity=A,
        friction=C,
    )

    hs.append(h.copy(deepcopy=True))
    ss.append(s.copy(deepcopy=True))
    us.append(u.copy(deepcopy=True))
100%|██████████| 600/600 [07:58<00:00,  1.25it/s]

The plot below shows the average thickness of the glacier over time. By the end of the interval the system has migrated towards a reasonably stable limit cycle.

import matplotlib.pyplot as plt

average_thicknesses = np.array([firedrake.assemble(h * dx) / (Lx * Ly) for h in hs])
times = np.linspace(0, final_time, num_steps + 1)

fig, ax = plt.subplots()
ax.set_xlabel("time (years)")
ax.set_ylabel("average thickness (meters)")
ax.plot(times, average_thicknesses);

Hindcasting

We're now going to see if we can recover the state of the system at time $t = 23.5$ from knowledge of the system state at time $t = 25$. The biggest departure in this notebook from the previous demonstration of statistical estimation problems is that now our simulation includes a full loop over all timesteps, rather than a single diagnostic solve. The simulation has to take in the controls (the unknown initial thickness) and return the observables (the final thickness). There are a few extra variables, like the start and end times and the mean and fluctuations of the accumulation rate, that come in implicitly but aren't actual function arguments.

start_time = 23.5
final_time = 25.0

def simulation(h_initial):
    a = firedrake.Function(Q2)
    h = h_initial.copy(deepcopy=True)
    s = icepack.compute_surface(thickness=h, bed=z_b)
    u = flow_solver.diagnostic_solve(
        velocity=us[-1].copy(deepcopy=True),
        thickness=h,
        surface=s,
        fluidity=A,
        friction=C,
    )
    t = Constant(start_time)

    num_steps = int((final_time - start_time) / dt)
    for step in tqdm.trange(num_steps):
        t = Constant(t + dt)
        a.interpolate(a_0 + δa * firedrake.sin(2 * π * t))

        h = flow_solver.prognostic_solve(
            dt,
            thickness=h,
            velocity=u,
            accumulation=a,
            thickness_inflow=h_in,
        )
        s = icepack.compute_surface(thickness=h, bed=z_b)

        u = flow_solver.diagnostic_solve(
            velocity=u,
            thickness=h,
            surface=s,
            fluidity=A,
            friction=C,
        )

    return h

The loss functional calculates how well the final thickness and velocity from the simulation matches that from the actual time series.

def loss_functional(h_final):
    σ_h = Constant(1.0)
    return 0.5 / area * ((h_final - hs[-1]) / σ_h)**2 * dx

In the previous demonstration of inverse methods, we used a prior that favored a smooth value of the fluidity:

$$R(\theta) = \frac{\alpha^2}{2}\int_\Omega|\nabla\theta|^2dx.$$

Here we have a little more knowledge; while the initial state might depart somewhat from the final state, we expect the difference between the two to be fairly smooth. So we'll instead use the prior

$$R(h(t_0)) = \frac{\alpha^2}{2}\int_\Omega|\nabla(h(t_1) - h(t_0))|^2dx.$$
def regularization(h_initial):
    α = Constant(0.0)
    δh = h_initial - hs[-1]
    return 0.5 * α**2 / area * inner(grad(δh), grad(δh)) * dx

As our starting guess for the initial thickness, we'll assume that it's equal to the final thickness.

h_initial = hs[-1].copy(deepcopy=True)

We've added a few extra options to pass to the optimizer in order to guarantee convergence to the right solution.

from icepack.statistics import (
    StatisticsProblem,
    MaximumProbabilityEstimator,
)

stats_problem = StatisticsProblem(
    simulation=simulation,
    loss_functional=loss_functional,
    regularization=regularization,
    controls=h_initial,
)

estimator = MaximumProbabilityEstimator(
    stats_problem,
    algorithm="bfgs",
    memory=10,
    gradient_tolerance=1e-12,
    step_tolerance=5e-14,
)
100%|██████████| 36/36 [00:20<00:00,  1.73it/s]
h_min = estimator.solve()
Quasi-Newton Method with Limited-Memory BFGS
Line Search: Cubic Interpolation satisfying Strong Wolfe Conditions
  iter  value          gnorm          snorm          #fval     #grad     ls_#fval  ls_#grad  
  0     1.024462e-02   8.614877e-07   
  1     1.024462e-02   8.614877e-07   8.614877e-07   2         2         1         0         
  2     3.050663e-03   3.876803e-07   1.477479e+04   3         3         1         0         
  3     1.130898e-03   3.054103e-07   8.520600e+03   4         4         1         0         
  4     1.275521e-04   9.164443e-08   1.001678e+04   5         5         1         0         
  5     6.827371e-05   5.346385e-08   1.953368e+03   6         6         1         0         
  6     5.096586e-05   2.016236e-08   5.881060e+02   7         7         1         0         
  7     4.483167e-05   1.715701e-08   3.739321e+02   8         8         1         0         
  8     3.257588e-05   1.768648e-08   1.281218e+03   9         9         1         0         
  9     2.471696e-05   1.543098e-08   1.070443e+03   10        10        1         0         
  10    1.944347e-05   2.722804e-08   2.562058e+03   11        11        1         0         
  11    1.185223e-05   9.961294e-09   6.395475e+02   12        12        1         0         
  12    9.539326e-06   9.068673e-09   3.687525e+02   13        13        1         0         
  13    4.151828e-06   8.609152e-09   1.623611e+03   14        14        1         0         
  14    3.124914e-06   8.017546e-09   4.793673e+02   16        15        2         0         
  15    2.077726e-06   5.068789e-09   6.121509e+02   17        16        1         0         
  16    1.302139e-06   4.229767e-09   3.768834e+02   18        17        1         0         
  17    7.108775e-07   3.828434e-09   4.472465e+02   19        18        1         0         
  18    3.722639e-07   2.032368e-09   2.223172e+02   20        19        1         0         
  19    2.319656e-07   1.617590e-09   1.702566e+02   21        20        1         0         
  20    1.850211e-07   2.314256e-09   1.510996e+02   22        21        1         0         
  21    1.326688e-07   9.215288e-10   4.438243e+01   23        22        1         0         
  22    1.081323e-07   8.618942e-10   4.509865e+01   24        23        1         0         
  23    8.280087e-08   9.161295e-10   6.795823e+01   25        24        1         0         
  24    4.885079e-08   1.331764e-09   1.395148e+02   26        25        1         0         
  25    3.799772e-08   1.478972e-09   1.389808e+02   27        26        1         0         
  26    2.039635e-08   4.577807e-10   3.754305e+01   28        27        1         0         
  27    1.601783e-08   3.943554e-10   1.506850e+01   29        28        1         0         
  28    8.401493e-09   4.683924e-10   4.916966e+01   30        29        1         0         
  29    8.207727e-09   8.570104e-10   5.028421e+01   31        30        1         0         
  30    3.260358e-09   1.816703e-10   1.532770e+01   32        31        1         0         
  31    2.679237e-09   1.243535e-10   6.330617e+00   33        32        1         0         
  32    1.853156e-09   1.083308e-10   1.316644e+01   34        33        1         0         
  33    1.113063e-09   1.032294e-10   1.453573e+01   35        34        1         0         
  34    8.774650e-10   1.218312e-10   8.322373e+00   37        35        2         0         
  35    5.899032e-10   6.254399e-11   8.786927e+00   38        36        1         0         
  36    4.160994e-10   5.160053e-11   6.051526e+00   39        37        1         0         
  37    3.146363e-10   1.053413e-10   1.081652e+01   40        38        1         0         
  38    2.158471e-10   4.514252e-11   2.674159e+00   41        39        1         0         
  39    1.874733e-10   2.806482e-11   1.515896e+00   42        40        1         0         
  40    1.575527e-10   2.470438e-11   2.182069e+00   43        41        1         0         
  41    1.126763e-10   4.910716e-11   4.982603e+00   44        42        1         0         
  42    8.396116e-11   3.857251e-11   5.274273e+00   45        43        1         0         
  43    6.346224e-11   1.991187e-11   1.239854e+00   46        44        1         0         
  44    4.736418e-11   1.738012e-11   1.694782e+00   47        45        1         0         
  45    2.841020e-11   1.673462e-11   2.719945e+00   48        46        1         0         
  46    1.545631e-11   2.123827e-11   3.783580e+00   49        47        1         0         
  47    9.618797e-12   1.339338e-11   2.177766e+00   50        48        1         0         
  48    6.771761e-12   5.968673e-12   6.856161e-01   51        49        1         0         
  49    5.523853e-12   5.319361e-12   3.974298e-01   52        50        1         0         
  50    3.833964e-12   7.674163e-12   8.624951e-01   53        51        1         0         
Optimization Terminated with Status: Iteration Limit Exceeded

The minimizer is appreciably different from the thickness at $t = 25.0$ and very to the value at $t = 23.5$, so the algorithm has reproduced the initial condition that we pretended not to know.

δh_end = h_min - hs[-1]
print(f"|h_min - h(25.0)|: {firedrake.norm(δh_end)}")
num_steps = int((final_time - start_time) / dt)
δh_start = h_min - hs[-1 - num_steps]
print(f"|h_min - h(23.5)|: {firedrake.norm(δh_start)}")
|h_min - h(25.0)|: 32164.43123901769
|h_min - h(23.5)|: 36.51652925878706
import icepack.plot

δh = interpolate(h_min - hs[-1 - num_steps], Q2)
fig, axes = icepack.plot.subplots()
axes.set_title("Estimated - True thickness")
colors = firedrake.tripcolor(
    δh, vmin=-0.002, vmax=+0.002, cmap="RdBu", axes=axes
)
fig.colorbar(colors, fraction=0.01, pad=0.046);

Conclusion

In previous demos, we've shown how use measurements of observable fields, like ice velocity and thickness, to estimate unknown parameters that satisfy constraints from a physics model. The physics model was fairly rudimentary before -- taking in a single field like the ice fluidity and returning the ice velocity as computed from the momentum conservation equation. Here we showed how to use much more complex simulations involving a full timestepping loop. Instead of estimating an unobservable parameter of the system, like the fluidity or friction coefficient, we instead showed how to estimate the thickness at a different time from when it was observed.

Solving these kinds of problems is more computationally expensive and finding better or faster algorithms is an active area of research. While costly, the capability does open up many more possible research directions and improvements on existing practice. For example, when estimating the ice fluidity or friction, it's common to assume that the thickness and velocity measurements were taken at the same time. This assumption is almost never exactly true. The ability to do time-dependent data assimilation means that we can dispense with it.