Perfect baroclinic adjustment calibration with Ensemble Kalman Inversion

This example showcases a "perfect model calibration" of the two-dimensional baroclinic adjustement problem (depth-latitude) with eddies parametrized using Gent-McWilliams–Redi isoneutral diffusion closure. We use output for buoyancy ($b$) and a passive-tracer concentration ($c$) to calibrate the parametrization.

Install dependencies

First let's make sure we have all required packages installed.

using Pkg
pkg"add Oceananigans, Distributions, CairoMakie, ParameterEstimocean"

First we load few things

using ParameterEstimocean

using Oceananigans
using Oceananigans.Units
using Oceananigans.TurbulenceClosures: FluxTapering
using Oceananigans.Models.HydrostaticFreeSurfaceModels: SliceEnsembleSize
using Distributions
using Printf
using LinearAlgebra: norm

Set up the problem and generate observations

Define the "true" skew and symmetric diffusivity coefficients. These are the parameter values that we use to generate the data. Then, we'll see if the EKI calibration can recover these values.

κ_skew = 1000.0       # [m² s⁻¹] skew diffusivity
κ_symmetric = 900.0   # [m² s⁻¹] symmetric diffusivity

We gather the "true" parameters in a named tuple $θ_*$:

θ★ = (; κ_skew, κ_symmetric)
(κ_skew = 1000.0, κ_symmetric = 900.0)

The experiment name and where the synthetic observations will be saved.

experiment_name = "baroclinic_adjustment"
data_path = experiment_name * ".jld2"
"baroclinic_adjustment.jld2"

The domain, number of grid points, and other parameters.

architecture = CPU()      # CPU or GPU?
Ly = 1000kilometers       # north-south extent [m]
Lz = 1kilometers          # depth [m]
Ny = 64                   # grid points in north-south direction
Nz = 16                   # grid points in the vertical
Δt = 10minute             # time-step
stop_time = 1days         # length of run
save_interval = 0.25days  # save observation every so often

force_generate_observations = false

horizontal_diffusivity = HorizontalScalarDiffusivity(κ=100)
vertical_diffusivity = VerticalScalarDiffusivity(κ=1e-2)
VerticalScalarDiffusivity{ExplicitTimeDiscretization}(ν=0.0, κ=0.01)

The isopycnal skew-symmetric diffusivity closure.

gerdes_koberle_willebrand_tapering = FluxTapering(1e-2)
gent_mcwilliams_diffusivity = IsopycnalSkewSymmetricDiffusivity(κ_skew = κ_skew,
                                                                κ_symmetric = κ_symmetric,
                                                                slope_limiter = gerdes_koberle_willebrand_tapering)
IsopycnalSkewSymmetricDiffusivity: (κ_symmetric=900.0, κ_skew=1000.0, (isopycnal_tensor=Oceananigans.TurbulenceClosures.SmallSlopeIsopycnalTensor{Float64}(0.0), slope_limiter=Oceananigans.TurbulenceClosures.FluxTapering{Float64}(0.01))

Generate synthetic observations

if force_generate_observations || !(isfile(data_path))
    grid = RectilinearGrid(architecture,
                           topology = (Flat, Bounded, Bounded),
                           size = (Ny, Nz),
                           y = (-Ly/2, Ly/2),
                           z = (-Lz, 0),
                           halo = (3, 3))

    closures = (gent_mcwilliams_diffusivity, horizontal_diffusivity, vertical_diffusivity)

    model = HydrostaticFreeSurfaceModel(; grid,
                                        tracers = (:b, :c),
                                        buoyancy = BuoyancyTracer(),
                                        coriolis = BetaPlane(latitude = -45),
                                        closure = closures)

    @info "Built $model."

    ##### Initial conditions of an unstable buoyancy front

    """
    Linear ramp from 0 to 1 between -Δy/2 and +Δy/2.

    For example:

    y < y₀           => ramp = 0
    y₀ < y < y₀ + Δy => ramp = y / Δy
    y > y₀ + Δy      => ramp = 1
    """
    ramp(y, Δy) = min(max(0, y/Δy + 1/2), 1)

    N² = 4e-6             # [s⁻²] buoyancy frequency / stratification
    M² = 8e-8             # [s⁻²] horizontal buoyancy gradient

    Δy = 50kilometers     # horizontal extent of the font

    Δc_y = 2Δy            # horizontal extent of initial tracer concentration
    Δc_z = 50             # [m] vertical extent of initial tracer concentration

    Δb = Δy * M²          # inital buoyancy jump

    bᵢ(x, y, z) = N² * z + Δb * ramp(y, Δy)
    cᵢ(x, y, z) = exp(-y^2 / 2Δc_y^2) * exp(-(z + Lz/2)^2 / (2Δc_z^2))

    set!(model, b=bᵢ, c=cᵢ)

    simulation = Simulation(model, Δt=Δt, stop_time=stop_time)

    simulation.output_writers[:fields] = JLD2OutputWriter(model, merge(model.velocities, model.tracers),
                                                          schedule = TimeInterval(save_interval),
                                                          filename = experiment_name,
                                                          array_type = Array{Float64},
                                                          with_halos = true,
                                                          overwrite_existing = true)

    run!(simulation)
end
┌ Info: Built HydrostaticFreeSurfaceModel{CPU, RectilinearGrid}(time = 0 seconds, iteration = 0)
│ ├── grid: 1×64×16 RectilinearGrid{Float64, Oceananigans.Grids.Flat, Oceananigans.Grids.Bounded, Oceananigans.Grids.Bounded} on Oceananigans.Architectures.CPU with 0×3×3 halo
│ ├── timestepper: QuasiAdamsBashforth2TimeStepper
│ ├── tracers: (b, c)
│ ├── closure: Tuple with 3 closures:
│ │   ├── IsopycnalSkewSymmetricDiffusivity(κ_skew=1000.0, κ_symmetric=900.0)
│ │   ├── HorizontalScalarDiffusivity{ExplicitTimeDiscretization}(ν=0.0, κ=(b=100.0, c=100.0))
│ │   └── VerticalScalarDiffusivity{ExplicitTimeDiscretization}(ν=0.0, κ=(b=0.01, c=0.01))
│ ├── buoyancy: Oceananigans.BuoyancyModels.BuoyancyTracer with ĝ = NegativeZDirection()
│ ├── free surface: Oceananigans.Models.HydrostaticFreeSurfaceModels.ImplicitFreeSurface with gravitational acceleration 9.80665 m s⁻²
│ │   └── solver: FFTImplicitFreeSurfaceSolver
└ └── coriolis: Oceananigans.Coriolis.BetaPlane{Float64}.
[ Info: Initializing simulation...
[ Info:     ... simulation initialization complete (2.110 seconds)
[ Info: Executing initial time step...
[ Info:     ... initial time step complete (48.404 seconds).
[ Info: Simulation is stopping after running for 1.035 minutes.
[ Info: Simulation time 1 day equals or exceeds stop time 1 day.

Load truth data as observations

We use here the Transformation functionality to slice up the observation data a bit. In particular, we choose to exclude the 3 grid points on either side of the y dimension, and 3 grid points from the bottom of the domain. Also, we only use the last 3 snapshots of the observations.

We use SpaceIndices and TimeIndices to denote which space-time indices we would like to keep in observations.

transformation = Transformation(space=SpaceIndices(y=4:Ny-3, z=4:Nz), time=TimeIndices(3:5), normalization=ZScore())
observations = SyntheticObservations(data_path; field_names=(:b, :c), transformation)
SyntheticObservations with fields (:b, :c)
├── times: [0 s, 6 hrs, 12 hrs, 18 hrs, 1 d]
├── grid: 1×64×16 RectilinearGrid{Float64, Oceananigans.Grids.Flat, Oceananigans.Grids.Bounded, Oceananigans.Grids.Bounded} on Oceananigans.Architectures.CPU with 0×3×3 halo
├── path: "baroclinic_adjustment.jld2"
├── metadata: (:grid, :coriolis, :closure)
└── transformation: Dict{Symbol, ParameterEstimocean.Transformations.Transformation{TimeIndices{UnitRange{Int64}}, SpaceIndices{Colon, UnitRange{Int64}, UnitRange{Int64}}, ZScore{Float64}}} with 2 entries

Calibration with Ensemble Kalman Inversion

Ensemble model

First we set up an ensemble model,

ensemble_size = 20

slice_ensemble_size = SliceEnsembleSize(size=(Ny, Nz), ensemble=ensemble_size)

@show ensemble_grid = RectilinearGrid(architecture,
                                      size=slice_ensemble_size,
                                      topology = (Flat, Bounded, Bounded),
                                      y = (-Ly/2, Ly/2),
                                      z = (-Lz, 0),
                                      halo=(3, 3))

gm_ensemble = [deepcopy(gent_mcwilliams_diffusivity) for i = 1:ensemble_size]
closures = (gm_ensemble, horizontal_diffusivity, vertical_diffusivity)

@show ensemble_model = HydrostaticFreeSurfaceModel(grid = ensemble_grid,
                                                   tracers = (:b, :c),
                                                   buoyancy = BuoyancyTracer(),
                                                   coriolis = BetaPlane(latitude = -45),
                                                   closure = closures)
HydrostaticFreeSurfaceModel{CPU, RectilinearGrid}(time = 0 seconds, iteration = 0)
├── grid: 20×64×16 RectilinearGrid{Float64, Oceananigans.Grids.Flat, Oceananigans.Grids.Bounded, Oceananigans.Grids.Bounded} on Oceananigans.Architectures.CPU with 0×3×3 halo
├── timestepper: QuasiAdamsBashforth2TimeStepper
├── tracers: (b, c)
├── closure: Tuple with 3 closures:
│   ├── 20-element Vector{Oceananigans.TurbulenceClosures.IsopycnalSkewSymmetricDiffusivity{Oceananigans.TurbulenceClosures.VerticallyImplicitTimeDiscretization, Float64, Float64, Oceananigans.TurbulenceClosures.SmallSlopeIsopycnalTensor{Float64}, Oceananigans.TurbulenceClosures.FluxTapering{Float64}}}
│   ├── HorizontalScalarDiffusivity{ExplicitTimeDiscretization}(ν=0.0, κ=(b=100.0, c=100.0))
│   └── VerticalScalarDiffusivity{ExplicitTimeDiscretization}(ν=0.0, κ=(b=0.01, c=0.01))
├── buoyancy: Oceananigans.BuoyancyModels.BuoyancyTracer with ĝ = NegativeZDirection()
├── free surface: Oceananigans.Models.HydrostaticFreeSurfaceModels.ImplicitFreeSurface with gravitational acceleration 9.80665 m s⁻²
│   └── solver: FFTImplicitFreeSurfaceSolver
└── coriolis: Oceananigans.Coriolis.BetaPlane{Float64}

and then we create an ensemble simulation:

ensemble_simulation = Simulation(ensemble_model; Δt, stop_time)

ensemble_simulation
Simulation of HydrostaticFreeSurfaceModel{CPU, RectilinearGrid}(time = 0 seconds, iteration = 0)
├── Next time step: 10 minutes
├── Elapsed wall time: 0 seconds
├── Wall time per iteration: NaN days
├── Stop time: 1 day
├── Stop iteration : Inf
├── Wall time limit: Inf
├── Callbacks: OrderedDict with 4 entries:
│   ├── stop_time_exceeded => Callback of stop_time_exceeded on IterationInterval(1)
│   ├── stop_iteration_exceeded => Callback of stop_iteration_exceeded on IterationInterval(1)
│   ├── wall_time_limit_exceeded => Callback of wall_time_limit_exceeded on IterationInterval(1)
│   └── nan_checker => Callback of NaNChecker for u on IterationInterval(100)
├── Output writers: OrderedDict with no entries
└── Diagnostics: OrderedDict with no entries

Free parameters

We construct some prior distributions for our free parameters. We found that it often helps to constrain the prior distributions so that neither very high nor very low values for diffusivities can be drawn out of the distribution.

priors = (κ_skew = ScaledLogitNormal(bounds=(400.0, 1300.0)),
          κ_symmetric = ScaledLogitNormal(bounds=(700.0, 1700.0)))

free_parameters = FreeParameters(priors)
FreeParameters with 2 free parameters and 0 dependent parameters
├── names: (:κ_skew, :κ_symmetric)
└── priors: 
    ├──      κ_skew => ScaledLogitNormal{Float64}(μ=0.0, σ=1.0, lower_bound=400.0, upper_bound=1300.0)
    └── κ_symmetric => ScaledLogitNormal{Float64}(μ=0.0, σ=1.0, lower_bound=700.0, upper_bound=1700.0)

To visualize the prior distributions we randomly sample out values from then and plot the p.d.f.

using CairoMakie
using ParameterEstimocean.Parameters: unconstrained_prior, transform_to_constrained

samples(prior) = [transform_to_constrained(prior, x) for x in rand(unconstrained_prior(prior), 10000000)]

samples_κ_skew = samples(priors.κ_skew)
samples_κ_symmetric = samples(priors.κ_symmetric)

fig = Figure()
ax = Axis(fig[1, 1], xlabel = "Diffusivities [m² s⁻¹]", ylabel = "PDF")
densities = []
push!(densities, density!(ax, samples_κ_skew))
push!(densities, density!(ax, samples_κ_symmetric))
Legend(fig[1, 2], densities, ["κ_skew", "κ_symmetric"], valign = :bottom, halign = :left)

The inverse problem

We can construct the inverse problem $y = G(θ) + η$. Here, $y$ are the observations and $G$ is the ensemble_model.

calibration = InverseProblem(observations, ensemble_simulation, free_parameters)
InverseProblem{ConcatenatedOutputMap} with free parameters (:κ_skew, :κ_symmetric)
├── observations: SyntheticObservations of (:b, :c) on 1×64×16 RectilinearGrid{Float64, Oceananigans.Grids.Flat, Oceananigans.Grids.Bounded, Oceananigans.Grids.Bounded} on Oceananigans.Architectures.CPU with 0×3×3 halo
├── simulation: Simulation on 20×64×16 RectilinearGrid{Float64, Oceananigans.Grids.Flat, Oceananigans.Grids.Bounded, Oceananigans.Grids.Bounded} on Oceananigans.Architectures.CPU with 0×3×3 halo with Δt=600.0
├── free_parameters: (:κ_skew, :κ_symmetric)
└── output map: ConcatenatedOutputMap

Assert that $G(θ_*) ≈ y$

As a sanity check we apply the forward_map on the calibration after we initialize all ensemble members with the true parameter values. We then confirm that the output of the forward_map matches the observations to machine precision.

G = forward_map(calibration, θ★)
y = observation_map(calibration)
[ Info: Initializing simulation...
[ Info:     ... simulation initialization complete (279.058 ms)
[ Info: Executing initial time step...
[ Info:     ... initial time step complete (39.128 seconds).
[ Info: Simulation is stopping after running for 1.292 minutes.
 [ Info: Simulation time 1 day equals or exceeds stop time 1 day.

The forward_map output G is a two-dimensional matrix whose first dimension is the size of the state space. Here, after the transformation we applied to the observations, we have that the state space size is $2 \times (N_y - 6) \times (N_z - 3) \times 3$; the 2 comes from the two tracers we used as observations and the 3 comes from only using the last three snapshots of the observations. The second dimension of the forward_map output is the ensemble_size.

@show size(G) == (2 * (Ny-6) * (Nz-3) * 3, ensemble_size)
true

Since above we computed G using the true parameters $θ_*$, all columns of the forward map output should be the same as the observations:

mean(G, dims=2) ≈ y
true

Next, we construct an EnsembleKalmanInversion (EKI) object,

eki = EnsembleKalmanInversion(calibration; pseudo_stepping=ConstantConvergence(0.4))
EnsembleKalmanInversion
├── inverse_problem: InverseProblem{ConcatenatedOutputMap} with free parameters (:κ_skew, :κ_symmetric)
├── ensemble_kalman_process: EnsembleKalmanProcesses.Inversion
├── mapped_observations: 4524-element Vector{Float64}
├── noise_covariance: 4524×4524 Matrix{Float64}
├── pseudo_stepping: ConstantConvergence{Float64}(0.4)
├── iteration: 0
├── resampler: Resampler{FullEnsembleDistribution}
├── unconstrained_parameters: 2×20 Matrix{Float64}
├── forward_map_output: 4524×20 Matrix{Float64}
└── mark_failed_particles: NormExceedsMedian{Float64}

and perform few iterations to see if we can converge to the true parameter values.

params = iterate!(eki; iterations = 5)

@show params
(κ_skew = 965.8469171390201, κ_symmetric = 911.0467700123145)

Last, we visualize few metrics regarding how the EKI calibration went about.

θ̅(iteration) = [eki.iteration_summaries[iteration].ensemble_mean...]
varθ(iteration) = eki.iteration_summaries[iteration].ensemble_var

weight_distances = [norm(θ̅(iter) - [θ★[1], θ★[2]]) for iter in 1:eki.iteration]
output_distances = [norm(forward_map(calibration, θ̅(iter))[:, 1] - y) for iter in 1:eki.iteration]
ensemble_variances = [varθ(iter) for iter in 1:eki.iteration]

f = Figure()
lines(f[1, 1], 1:eki.iteration, weight_distances, color = :red, linewidth = 2,
      axis = (title = "Parameter distance",
              xlabel = "Iteration",
              ylabel="|θ̅ₙ - θ⋆|",
              yscale = log10))
lines(f[1, 2], 1:eki.iteration, output_distances, color = :blue, linewidth = 2,
      axis = (title = "Output distance",
              xlabel = "Iteration",
              ylabel="|G(θ̅ₙ) - y|",
              yscale = log10))
ax3 = Axis(f[2, 1:2], title = "Parameter convergence",
           xlabel = "Iteration",
           ylabel = "Ensemble variance",
           yscale = log10)

for (i, pname) in enumerate(free_parameters.names)
    ev = getindex.(ensemble_variances, i)
    lines!(ax3, 1:eki.iteration, ev / ev[1], label = String(pname), linewidth = 2)
end

axislegend(ax3, valign = :top, halign = :right)
[ Info: Initializing simulation...
[ Info:     ... simulation initialization complete (2.145 ms)
[ Info: Executing initial time step...
[ Info:     ... initial time step complete (205.106 ms).
[ Info: Simulation is stopping after running for 37.727 seconds.
 [ Info: Simulation time 1 day equals or exceeds stop time 1 day.

[ Info: Initializing simulation...
[ Info:     ... simulation initialization complete (2.095 ms)
[ Info: Executing initial time step...
[ Info:     ... initial time step complete (200.755 ms).
[ Info: Simulation is stopping after running for 38.135 seconds.
[ Info: Simulation time 1 day equals or exceeds stop time 1 day.
[ Info: Initializing simulation...
[ Info:     ... simulation initialization complete (2.405 ms)
[ Info: Executing initial time step...
[ Info:     ... initial time step complete (209.542 ms).
[ Info: Simulation is stopping after running for 37.990 seconds.
[ Info: Simulation time 1 day equals or exceeds stop time 1 day.
[ Info: Initializing simulation...
[ Info:     ... simulation initialization complete (2.168 ms)
[ Info: Executing initial time step...
[ Info:     ... initial time step complete (219.855 ms).
[ Info: Simulation is stopping after running for 37.838 seconds.
[ Info: Simulation time 1 day equals or exceeds stop time 1 day.
[ Info: Initializing simulation...
[ Info:     ... simulation initialization complete (1.895 ms)
[ Info: Executing initial time step...
[ Info:     ... initial time step complete (211.794 ms).
[ Info: Simulation is stopping after running for 37.809 seconds.
[ Info: Simulation time 1 day equals or exceeds stop time 1 day.

And also we plot the the distributions of the various model ensembles for few EKI iterations to see if and how well they converge to the true diffusivity values.

f = Figure()

axtop = Axis(f[1, 1])

axmain = Axis(f[2, 1],
              xlabel = "κ_skew [m² s⁻¹]",
              ylabel = "κ_symmetric [m² s⁻¹]")

axright = Axis(f[2, 2])
scatters = []
labels = String[]

for iteration in [0, 1, 2, 5]
    # Make parameter matrix
    parameters = eki.iteration_summaries[iteration].parameters
    Nensemble = length(parameters)
    Nparameters = length(first(parameters))
    parameter_ensemble_matrix = [parameters[i][j] for i=1:Nensemble, j=1:Nparameters]

    label = iteration == 0 ? "Initial ensemble" : "Iteration $iteration"
    push!(labels, label)
    push!(scatters, scatter!(axmain, parameter_ensemble_matrix))
    density!(axtop, parameter_ensemble_matrix[:, 1])
    density!(axright, parameter_ensemble_matrix[:, 2], direction = :y)
end

vlines!(axmain, [κ_skew], color = :red)
vlines!(axtop, [κ_skew], color = :red)

hlines!(axmain, [κ_symmetric], color = :red)
hlines!(axright, [κ_symmetric], color = :red)

colsize!(f.layout, 1, Fixed(300))
colsize!(f.layout, 2, Fixed(200))

rowsize!(f.layout, 1, Fixed(200))
rowsize!(f.layout, 2, Fixed(300))

Legend(f[1, 2], scatters, labels, valign = :bottom, halign = :left)

hidedecorations!(axtop, grid = false)
hidedecorations!(axright, grid = false)

xlims!(axmain, 350, 1350)
xlims!(axtop, 350, 1350)
ylims!(axmain, 650, 1750)
ylims!(axright, 650, 1750)
xlims!(axright, 0, 0.015)
ylims!(axtop, 0, 0.015)


This page was generated using Literate.jl.