GeoStack · six-step cycle · L3 → L4 row

Ocean state estimation, from the generative story to each box

The traditional formulation of estimating the ocean state from sea-level maps, written once, then read box by box. Steps 1 and 2 are the classical problem. Steps 3 to 6 are what that problem turns into when parts of it are learned.

TL;DR

Every box is an operator

Before any equation, fix what goes in and what comes out. For this problem the answer is short:

So the task picks the signature, and the box picks the implementation. Swapping box 2 for box 5 changes no wiring around it.

KindDimensionsExample
scalar()a cost, a score
vector(param,)θ = (L_d, ν_visc); α uses (hyper,)
field(height, width)one SSH snapshot
mask(time, height, width)observed pixels, boolean
cube(time, height, width)a window of maps or states
ensemble(member, time, height, width)EnKS members, posterior samples
dataset(sim, time, height, width)simulated windows; real ones use window
weights—network parameters θ_emu, ν

Dimension names: time is days in the window (states include day 0, so they have one more), height and width are the grid rows and columns, variable stacks several fields, member is an ensemble member, sim a simulated window, window a real one, param and hyper index θ and α.

The problem and the data

Take a box in the Gulf Stream extension, about 1000 km on a side, centred near 40°N. Satellite altimetry has already been gridded into daily maps of sea surface height: that was the L2 → L3 step, optimal interpolation from along-track data to a map with an error estimate. What we want is the L4 product: the full dynamical ocean state over a window of days, consistent with the physics and with the maps. Run forward in real time, this is an analysis and forecast. Run backward over a whole window, it is a reanalysis.

The difference from L2 → L3 is the model. An optimal-interpolation map uses only a covariance. A state estimate uses a dynamical model that carries information between days, so a feature seen on Monday constrains the field on Thursday.

Running exampleValue
Domain, grid1024 km × 1024 km, 128 × 128, Δs = 8 km
Coriolis at 40°N, its gradientf₀ ≈ 9.4 × 10⁻⁵ s⁻¹, β ≈ 1.75 × 10⁻¹¹ m⁻¹ s⁻¹
Rossby deformation radiusL_d ≈ 30 km
Model time step, observation intervalΔt = 30 min, one SSH map per day (48 steps)
Assimilation window10 days: T = 10 maps, 480 model steps
State dimensionD_x = 128² = 16 384

Notation

One letter per role, used the same way in every box.

SymbolRoleIn the running exampleKind, shape
\(\vect{x}_k\)state, what we estimateSSH field \(x(\vect{s}, t_k)\) on the grid at day kfield \(\mathbb{R}^{D_x}\) (height, width); cube \(\mathbb{R}^{(T+1) \times D_x}\) (time, height, width) over the window
\(\vect{u}^\text{cov}\)covariates: known, read by the model, never estimatedplanetary vorticity βs₂, grid coordinates\(\mathbb{R}^{N_\text{var} \times D_x}\) (variable, height, width)
\(\vect{u}_k\)controls: external forcingdaily wind-stress curl\(\mathbb{R}^{D_x}\) per day; cube \(\mathbb{R}^{T \times D_x}\) (time, height, width)
\(\vect{y}_k\)observationsdaily L3 SSH map\(\mathbb{R}^{D_{y,k}}\) per day; cube (time, height, width)
\(\vect{m}_k\)mask: which pixels of \(\vect{y}_k\) are observedcoverage of each map\(\{0, 1\}^{D_x}\) per day; boolean cube (time, height, width)
\(\gvect{\theta}\)parameters of the physics\((L_d, \nu_\text{visc})\)\(\mathbb{R}^{D_\theta}\) (param,)
\(\gvect{\alpha}\)hyperparameters of the probability model\(\gmat{\Sigma}_x\)'s scale and length \((\sigma_x, \ell_x)\), θ's prior \((\gvect{\mu}_\theta, \gmat{\Sigma}_\theta)\), \(\gmat{\Sigma}_\varepsilon\) and \(\gmat{\Sigma}_\eta\) scales, localisation radius, inflation \(\alpha_\text{infl}\)\(\mathbb{R}^{D_\alpha}\) (hyper,)
\(\gvect{\theta}_\text{emu}\)emulator weightsbox 3's network\(\mathbb{R}^{D_{\theta_\text{emu}}}\) (weights)
\(\gvect{\nu}\)variational parametersbox 5's conditional density \(q(\cdot; \gvect{\nu})\)\(\mathbb{R}^{D_\nu}\) (weights)
Two clashes with DA vocabulary

DA papers call the variable 4D-Var optimises the "control vector". Here that is \((\vect{x}_0,\gvect{\theta})\), not \(\vect{u}\); \(\vect{u}\) is forcing we are given. And DA papers write the background as \(\mathbf{x}_b\) and \(\mathbf{B}\); here they are the prior mean \(\gvect{\mu}_x\) and covariance \(\gmat{\Sigma}_x\).

The mathematical objects

Every symbol below is used in the rest of the page. Index \(k = 0,1,\ldots,T\) counts observation times \(t_k\), one per day.

State

The state \(\vect{x}_k \in \mathbb{R}^{D_x}\) is the sea surface height field \(x(\vect{s}, t)\) on the grid at time \(t_k\), flattened to a vector. In the quasi-geostrophic (QG) model the streamfunction and potential vorticity are diagnosed from it, so \(x\) alone is a complete state.

Dynamics: the 1.5-layer QG model

Let \(g\) be gravity, \(\psi\) the streamfunction, \(q\) the potential vorticity, \(\vect{s} = (s_1, s_2)\) the eastward and northward coordinates, \(\nabla^2\) the horizontal Laplacian, \(J(a,b) = \partial_{s_1} a \partial_{s_2} b - \partial_{s_2} a \partial_{s_1} b\) the Jacobian (advection of \(b\) by the flow of \(a\)), and \(\nu_\text{visc}\) a biharmonic viscosity. Then

\[ \psi = \frac{g}{f_0} x, \] \[ q = \nabla^2\psi - \frac{\psi}{L_d^2} + \beta s_2, \] \[ \partial_t q + J(\psi, q) = -\nu_\text{visc} \nabla^6\psi + u . \]

One time step advects \(q\) (Arakawa Jacobian), then inverts the Helmholtz operator \(\nabla^2 - L_d^{-2}\) to recover \(\psi\), hence \(x\). The forcing \(u = \nabla\times\gvect{\tau}/(\rho_0 H_0)\) is the wind-stress curl from an atmospheric reanalysis, with \(\rho_0\) the reference density and \(H_0\) the layer depth; it is a control \(\vect{u}_k\), given for each day. The planetary vorticity \(\beta s_2\) and the grid coordinates are covariates \(\vect{u}^\text{cov}\). Collect the physical parameters in \(\gvect{\theta} = (L_d, \nu_\text{visc})\). Integrating 48 steps from \(t_{k-1}\) to \(t_k\) defines the model operator \(\mathcal{F}[\cdot; \gvect{\theta}] : \mathbb{R}^{D_x} \to \mathbb{R}^{D_x}\) and its composition \(\mathcal{F}_{k \leftarrow 0}\) from \(t_0\) to \(t_k\):

\[ \vect{x}_k = \mathcal{F}[\vect{x}_{k-1}, \vect{u}^\text{cov}, \vect{u}_k; \gvect{\theta}] + \gvect{\eta}_k, \] \[ \mathcal{F}_{k \leftarrow 0} = \underbrace{\mathcal{F} \circ \ldots \circ \mathcal{F}}_{k \text{ times}} . \]

Here \(\gvect{\eta}_k \sim \mathcal{N}(\vect{0}, \gmat{\Sigma}_\eta)\) is model error, with covariance \(\gmat{\Sigma}_\eta \in \mathbb{R}^{D_x\times D_x}\). Its Jacobian \(\mat{F}_k = \mat{J}_{\mathcal{F}}(\vect{x}_{k-1})\) is the tangent-linear model; \(\mat{F}_k^\top\) is the adjoint.

Observations

The map on day \(k\) has \(D_{y,k} \le D_x\) usable pixels (land, ice and high-error pixels are removed). Write \(\vect{y}_k \in \mathbb{R}^{D_{y,k}}\) for those pixels and \(\mat{H}_k \in \{0,1\}^{D_{y,k}\times D_x}\) for the selection matrix that picks them out of the state:

\[ \vect{y}_k = \mat{H}_k \vect{x}_k + \gvect{\varepsilon}_k, \] \[ \gvect{\varepsilon}_k \sim \mathcal{N}(\vect{0}, \gmat{\Sigma}_{\varepsilon,k}). \]

\(\gmat{\Sigma}_{\varepsilon,k} \in \mathbb{R}^{D_{y,k}\times D_{y,k}}\) is the observation-error covariance. For a gridded map it is the mapping error from the L2 → L3 optimal interpolation, plus representation error (the QG model has no tides, no steric seasonal cycle).

Watch out

Assimilating maps instead of tracks is convenient but not innocent. The map's errors are spatially correlated, so a diagonal \(\gmat{\Sigma}_{\varepsilon,k}\) overweights the data, and the map already contains a prior from the L2 → L3 step that is counted again here. The cleaner traditional choice is to assimilate the L2 along-track heights directly, with \(\mat{H}_k\) an interpolation from the grid to the track points. The algebra below is identical in both cases.

Prior

The background \(\gvect{\mu}_x\) is our best guess of \(\vect{x}_0\) before seeing this window, typically the previous window's analysis. Its error covariance \(\gmat{\Sigma}_x \in \mathbb{R}^{D_x\times D_x}\) is usually a stationary kernel, for example Matérn with a standard deviation of 10 cm and a length scale of 100 km. The parameters get their own prior \(\gvect{\theta} \sim \mathcal{N}(\gvect{\mu}_\theta, \gmat{\Sigma}_\theta)\). Everything that shapes these distributions but is not itself estimated in a window (\(\sigma_x, \ell_x, \gvect{\mu}_\theta, \gmat{\Sigma}_\theta\), the scales of \(\gmat{\Sigma}_\varepsilon\) and \(\gmat{\Sigma}_\eta\)) is a hyperparameter, collected in \(\gvect{\alpha}\).

\[ \vect{x}_0 \sim \mathcal{N}(\gvect{\mu}_x, \gmat{\Sigma}_x), \] \[ \gvect{\theta} \sim \mathcal{N}(\gvect{\mu}_\theta, \gmat{\Sigma}_\theta). \]

Dictionary to the figure. The figure writes the generic problem as \(y = H(f(\theta)) + \varepsilon\), where \(\theta\) is "the quantity we want". For ocean state estimation that quantity is the unknown initial state and parameters, the pair \((\vect{x}_0, \gvect{\theta})\). The figure's \(f\) is the trajectory map \((\vect{x}_0,\gvect{\theta}) \mapsto (\mathcal{F}_{1 \leftarrow 0}[\vect{x}_0; \gvect{\theta}],\ldots,\mathcal{F}_{T \leftarrow 0}[\vect{x}_0; \gvect{\theta}])\), and its \(H\) is the stack of the \(\mat{H}_k\). The figure's θ is therefore our \((\vect{x}_0, \gvect{\theta})\), and its "inner loop: x, θ" means our \(\vect{x}\) and \(\gvect{\theta}\). The covariates \(\vect{u}^\text{cov}\) and controls \(\vect{u}\) are inside \(f\): known inputs, not unknowns.

Step 1

Simple model: the generative story

Operatorsimulate_windowbox 1 · one call per synthetic window
Inputs
\(\gvect{\theta}\) physicsdeformation radius, viscosity
(param,)
\(\vect{x}_0\) initial SSHdrawn from the prior, or given
(height, width)
\(\vect{u}^\text{cov}\) covariatesβy and coordinates
(variable, height, width)
\(\vect{u}_{1:T}\) controlsdaily wind-stress curl
(time, height, width)
\(\vect{m}_{1:T}\) observing maskswhich pixels each real map covers
(time, height, width)
\(\gvect{\sigma}_{\varepsilon,1:T}\) observation errorper-pixel standard deviation (diagonal Σ_ε)
(time, height, width)
↓
\(\vect{x}_k = \mathcal{F}[\vect{x}_{k-1}, \vect{u}^\text{cov}, \vect{u}_k; \gvect{\theta}]\) ten times, then \(\vect{y}_k = \vect{m}_k \odot (\vect{x}_k + \gvect{\varepsilon}_k)\), with \(\gvect{\varepsilon}_k \sim \mathcal{N}(\vect{0}, \mathrm{diag}(\gvect{\sigma}_{\varepsilon,k}^2))\)
Outputs
\(\vect{x}_{0:T}\) true statesthe nature run, known exactly
(time, height, width)
\(\vect{y}_{1:T}\) synthetic mapsmasked like the real ones
(time, height, width)
Inside it: step_day 𝓕[·; θ], (state field, covariate field, control field, θ) → state field (48 QG steps). Called S times it builds the OSSE dataset that boxes 3 and 5 train on: states (sim, time, height, width), maps and masks (sim, time, height, width), controls (sim, time, height, width), θ (sim, param).

The objects above define a state-space model, a hidden Markov chain with Gaussian noise. Read top to bottom, it is a recipe for simulating an ocean and the satellite data it would produce:

\[ \begin{aligned} \gvect{\theta} &\sim \mathcal{N}(\gvect{\mu}_\theta, \gmat{\Sigma}_\theta) &&\text{draw the physics}\\ \vect{x}_0 &\sim \mathcal{N}(\gvect{\mu}_x, \gmat{\Sigma}_x) &&\text{draw an initial sea level}\\ \vect{x}_k &= \mathcal{F}[\vect{x}_{k-1}, \vect{u}^\text{cov}, \vect{u}_k; \gvect{\theta}] + \gvect{\eta}_k, && k = 1, \ldots, T, \text{ run the QG model}\\ \vect{y}_k &= \mat{H}_k \vect{x}_k + \gvect{\varepsilon}_k, && k = 1, \ldots, T, \text{ observe it} \end{aligned} \]

The recipe is equivalent to a joint density, which is everything step 2 needs:

\[ p(\vect{x}_{0:T}, \gvect{\theta}, \vect{y}_{1:T}) = p(\gvect{\theta}) p(\vect{x}_0)\prod_{k=1}^{T} \underbrace{p(\vect{x}_k \mid \vect{x}_{k-1}, \gvect{\theta})}_{\mathcal{N}(\mathcal{F}[\vect{x}_{k-1}; \gvect{\theta}], \gmat{\Sigma}_\eta)} \underbrace{p(\vect{y}_k \mid \vect{x}_k)}_{\mathcal{N}(\mat{H}_k\vect{x}_k, \gmat{\Sigma}_{\varepsilon,k})} . \]

The arrow from step 1 to steps 3 and 5 in the figure is this recipe run many times. In DA vocabulary that is an observing system simulation experiment (OSSE): a "nature run" \(\vect{x}^\text{true}_{0:T}\) plus synthetic maps \(\vect{y}_{1:T}\) made with the real \(\mat{H}_k\) and \(\gmat{\Sigma}_{\varepsilon,k}\). Each OSSE gives a pair \((\vect{x}_{0:T}, \vect{y}_{1:T})\) whose answer is known exactly.

Step 2 · oracle

Model-based inference: the classical ways

Operatorassimilate_windowbox 2 · one call per real 10-day window
Inputs
\(\vect{y}_{1:T}\) SSH mapsreal L3 maps, one per day
(time, height, width)
\(\vect{m}_{1:T}\) masksobserved pixels; this is H
(time, height, width)
\(\gvect{\sigma}_{\varepsilon,1:T}\) map errorper-pixel, from L2 → L3; this is Σ_ε
(time, height, width)
\(\gvect{\mu}_x\) backgroundprevious window's analysis
(height, width)
\(\vect{u}^\text{cov}\) covariatesβy and coordinates
(variable, height, width)
\(\vect{u}_{1:T}\) controlsdaily wind-stress curl
(time, height, width)
\(\gvect{\alpha}\) hyperparametersΣ_x's (σ_x, ℓ_x), θ's prior, Σ_ε and Σ_η scales, localisation, inflation
(hyper,)
↓
minimise \(\mathcal{J}(\vect{x}_0,\gvect{\theta})\) through 𝓕 and its adjoint (4D-Var), or forecast and update an ensemble (EnKF/EnKS)
Outputs
\(\hat{\vect{x}}_{0:T}\) analysed statesthe reanalysis of this window
(time, height, width)
\(\hat{\gvect{\theta}}\) estimated physicsonly if θ is in the control vector
(param,)
uncertaintyEnKS members, or for 4D-Var r Hessian eigenvectors (rank, height, width) + eigenvalues (rank,)
(member, time, height, width)
Streaming variant (filter): field in per day, field out per day, carrying the state between calls. Internally, 𝒥 maps field + vector → scalar and ∇𝒥 maps it back to field + vector.

Bayes' rule turns the joint density into the target. Everything in classical DA is a way of computing, approximating or summarising one of two posteriors:

\[ p(\vect{x}_{0:T}, \gvect{\theta} \mid \vect{y}_{1:T}) \propto p(\gvect{\theta}) p(\vect{x}_0)\prod_{k=1}^{T} p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}) p(\vect{y}_k \mid \vect{x}_k). \]
PosteriorName in DAProduct
\(p(\vect{x}_k \mid \vect{y}_{1:k})\)filteringreal-time analysis, initial conditions for a forecast
\(p(\vect{x}_{0:T} \mid \vect{y}_{1:T})\)smoothingreanalysis: every day uses past and future maps

The exact case: the Kalman filter

If \(\mathcal{F}\) were linear, \(\mathcal{F}[\vect{x}; \gvect{\theta}] = \mat{F}\vect{x}\), every density above would stay Gaussian and the filter would be exact. Write \(\vect{x}_{k \mid k-1}, \gmat{\Sigma}_{x,k \mid k-1}\) for the forecast mean and covariance and \(\vect{x}_{k \mid k}, \gmat{\Sigma}_{x,k \mid k}\) for the analysis:

\[ \begin{aligned} \text{forecast:} & \vect{x}_{k \mid k-1} = \mat{F}\vect{x}_{k-1 \mid k-1}, && \gmat{\Sigma}_{x,k \mid k-1} = \mat{F}\gmat{\Sigma}_{x,k-1 \mid k-1}\mat{F}^\top + \gmat{\Sigma}_\eta,\\ \text{gain:} & \mat{G}_k = \gmat{\Sigma}_{x,k \mid k-1}\mat{H}_k^\top(\mat{H}_k\gmat{\Sigma}_{x,k \mid k-1}\mat{H}_k^\top + \gmat{\Sigma}_{\varepsilon,k})^{-1}, &&\\ \text{analysis:} & \vect{x}_{k \mid k} = \vect{x}_{k \mid k-1} + \mat{G}_k(\vect{y}_k - \mat{H}_k\vect{x}_{k \mid k-1}), && \gmat{\Sigma}_{x,k \mid k} = (\mat{I} - \mat{G}_k\mat{H}_k)\gmat{\Sigma}_{x,k \mid k-1} . \end{aligned} \]

Two facts kill it for the ocean. QG advection \(J(\psi,q)\) is quadratic, so \(\mathcal{F}\) is nonlinear. And \(\gmat{\Sigma}_{x,k \mid k-1}\) has \(D_x^2 \approx 2.7\times 10^8\) entries (about 1 GB in float32) and propagating it costs \(D_x\) tangent-linear runs per day. Every practical method below drops one of these two ingredients: 4D-Var drops the covariance, the EnKF drops the full rank.

3D-Var: one analysis as an optimisation

For a single time, the Kalman analysis mean is also the minimiser of a quadratic cost. Write \( \Vert \vect{r} \Vert^2_{\mat{A}} = \vect{r}^\top \mat{A} \vect{r}\):

\[ \mathcal{J}_{\text{3D}}(\vect{x}) = \frac{1}{2} \Vert \vect{x} - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}} + \frac{1}{2} \Vert \vect{y} - \mat{H}\vect{x} \Vert^2_{\gmat{\Sigma}_\varepsilon^{-1}} . \]

This is \(-\log\) of prior times likelihood, so its minimiser is the MAP estimate. With \(\gvect{\mu}_x = \vect{x}_{k \mid k-1}\) and \(\gmat{\Sigma}_x = \gmat{\Sigma}_{x,k \mid k-1}\) it equals \(\vect{x}_{k \mid k}\) exactly. 3D-Var ignores time: it is the L2 → L3 optimal interpolation written as a cost function.

Strong-constraint 4D-Var: the whole window through the model

Assume the model is perfect, \(\gmat{\Sigma}_\eta = \mat{0}\). Then the trajectory is fixed by \(\vect{x}_0\) and \(\gvect{\theta}\), and the negative log posterior is a function of those alone:

\[ \mathcal{J}(\vect{x}_0,\gvect{\theta}) = \underbrace{\frac{1}{2} \Vert \vect{x}_0 - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}}}_{\text{background}} + \underbrace{\frac{1}{2} \Vert \gvect{\theta} - \gvect{\mu}_\theta \Vert^2_{\gmat{\Sigma}_\theta^{-1}}}_{\text{parameters}} + \underbrace{\frac{1}{2}\sum_{k=1}^{T} \Vert \vect{y}_k - \mat{H}_k\mathcal{F}_{k \leftarrow 0}[\vect{x}_0; \gvect{\theta}] \Vert^2_{\gmat{\Sigma}_{\varepsilon,k}^{-1}}}_{\text{misfit to 10 maps}} . \]

The minimiser \((\vect{x}_0^\star,\gvect{\theta}^\star)\) is the MAP estimate, and running the model from it gives the analysed trajectory. The minimisation needs \(\nabla_{\vect{x}_0}\mathcal{J}\). Let \(\vect{d}_k = \gmat{\Sigma}_{\varepsilon,k}^{-1}(\vect{y}_k - \mat{H}_k\vect{x}_k)\) be the weighted misfit on day \(k\). Run the adjoint backwards, starting from \(\vect{x}^\dagger_{T+1} = \vect{0}\):

\[ \vect{x}^\dagger_k = \mat{H}_k^\top\vect{d}_k + \mat{F}_{k+1}^\top\vect{x}^\dagger_{k+1} \text{ for } k = T, \ldots, 1, \] \[ \nabla_{\vect{x}_0}\mathcal{J} = \gmat{\Sigma}_x^{-1}(\vect{x}_0 - \gvect{\mu}_x) - \mat{F}_1^\top\vect{x}^\dagger_1 . \]

The backward recursion is reverse-mode automatic differentiation of the model. In JAX it is jax.grad of \(\mathcal{J}\); in a Fortran ocean model it is a separately maintained adjoint code. One gradient costs about one forward run plus one adjoint run, two to three forward runs in total. Adding \(\gvect{\theta}\) to the control vector, as above, is parameter estimation by the same machinery.

In practice the optimisation variable is changed to \(\vect{x}_0 = \gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi}\), so the background term becomes \(\frac{1}{2} \Vert \gvect{\xi} \Vert^2\) and the problem is well conditioned. Applying \(\gmat{\Sigma}_x^{1/2}\) matrix-free (a Kronecker or spectral factor of the Matérn kernel) is a linear-algebra job, which is where gaussx sits.

import einx
import equinox as eqx
import finitevolx as fvx
import gaussx as gx
import jax
import jax.numpy as jnp
import lineax as lx
import optimistix as optx
from jaxtyping import Array, Bool, Float

# Shapes: T = 10 daily maps (time); H, W = 130 × 130 grid with ghost ring
#         (height, width); N = H·W = 16,900 cells (cell); V covariates
#         (variable); P = 2 parameters L_d, ν_visc (param)

Op = lx.AbstractLinearOperator


class Window(eqx.Module):
    """One 10-day window: maps y, masks m, errors σ², background μ_x, priors."""

    maps: Float[Array, "time height width"]
    masks: Bool[Array, "time height width"]
    obs_var: Float[Array, "time height width"]
    background: Float[Array, "cell"]
    covariates: Float[Array, "variable height width"]
    forcing: Float[Array, "time height width"]
    prior_cov: Op
    param_mean: Float[Array, "param"]
    param_cov: Op
    grid: fvx.CartesianGrid2D


def strong_4dvar_cost(
    control: tuple[Float[Array, "cell"], Float[Array, "param"]],
    window: Window,
) -> Float[Array, ""]:
    whitened, params = control
    height = window.maps.shape[1]
    # user (taxonomy page)
    qg_params = QGParams(deformation_radius=params[0], viscosity=params[1])

    # --- background ---
    # today* (gaussx)
    # Σ_x^{1/2}
    prior_sqrt = gx.sqrt(window.prior_cov)            # (N, N) PSD op
    # x₀ − μ_x = Σ_x^{1/2} ξ
    increment = prior_sqrt.mv(whitened)               # (N,) → (N,)
    # x₀ = μ_x + Σ_x^{1/2} ξ
    initial_flat = window.background + increment      # (N,) → (N,)
    initial_state = einx.rearrange(                   # (N,) → (H, W)
        "(height width) -> height width", initial_flat, height=height
    )
    # ‖ξ‖²
    whitened_norm = einx.dot("cell, cell ->", whitened, whitened)  # → ()
    # 𝒥_b = ½‖ξ‖²
    background_cost = 0.5 * whitened_norm             # → ()

    # --- parameters ---
    # θ − μ_θ
    param_departure = params - window.param_mean      # (P,) → (P,)
    # today (gaussx)
    # (θ − μ_θ)ᵀ Σ_θ⁻¹ (θ − μ_θ)
    param_quad = gx.quadratic_form(window.param_cov, param_departure)  # → ()
    # 𝒥_θ = ½‖θ − μ_θ‖²_{Σ_θ⁻¹}
    param_cost = 0.5 * param_quad                     # → ()

    # --- observations, one day per scan step ---
    def assimilate_day(state, day):
        observed_map, mask, obs_var, daily_forcing = day
        # user (qg_day, taxonomy page)
        # x_k = 𝓕[x_{k−1}, u^cov, u_k; θ], 48 RK4 steps
        state = qg_day(                               # (H, W) → (H, W)
            state, window.covariates, daily_forcing, qg_params, window.grid
        )
        # y_k − x_k
        misfit = observed_map - state                 # (H, W)
        # r_k = m_k ⊙ (y_k − x_k)
        residual = jnp.where(mask, misfit, 0.0)       # (H, W)
        # r_k²
        squared = residual**2                         # (H, W)
        # r_k² / σ_k²
        weighted = squared / obs_var                  # (H, W)
        # ½ ∑ r_k² / σ_k²
        day_sum = einx.sum("height width ->", weighted)  # → ()
        day_cost = 0.5 * day_sum                      # → ()
        return state, day_cost

    days = (window.maps, window.masks, window.obs_var, window.forcing)
    # 𝒥_o = ∑_k ½‖m_k ⊙ (y_k − x_k)‖²_{Σ_ε,k⁻¹}
    _, day_costs = jax.lax.scan(assimilate_day, initial_state, days)  # → (T,)
    obs_cost = einx.sum("time ->", day_costs)         # (T,) → ()

    # 𝒥 = 𝒥_b + 𝒥_θ + 𝒥_o
    total_cost = background_cost + param_cost + obs_cost  # → ()
    return total_cost


# not pinned (optimistix)
# (ξ⋆, θ⋆) = argmin 𝒥(ξ, θ); ∇𝒥 by reverse mode is the adjoint recursion
solution = optx.minimise(                             # → ((N,), (P,))
    strong_4dvar_cost,
    optx.BFGS(rtol=1e-6, atol=1e-6),
    (whitened_init, params_init),
    args=window,
)
whitened_star, params_star = solution.value

Markers: today ships on main (checked 24 Oct 2026: finitevolX 831c69f, gaussx eeda235, somax 9cb19b3); today* ships, arguments sketched; user is defined on these pages; not pinned is a library whose API was not checked. Shape column letters are defined in the header.

vardax ships a strong-constraint 4D-Var (StrongFourDVar) but no Σx1/2 change of variables, so the sketch builds that step with gaussx.

Writing \(\mat{H}_k\) as the mask \(\vect{m}_k\) applied to the full grid keeps every day the same shape, (height, width), so the scan needs no padding. The hyperparameters \(\gvect{\alpha}\) live inside prior_cov, obs_var and param_cov: fixed here, tuned in step 6.

Weak-constraint 4D-Var: admitting the model is wrong

With \(\gmat{\Sigma}_\eta \neq \mat{0}\) the whole trajectory becomes the control variable, and the cost is exactly the negative log of the smoothing posterior:

\[ \mathcal{J}(\vect{x}_{0:T}) = \frac{1}{2} \Vert \vect{x}_0 - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}} + \frac{1}{2}\sum_{k=1}^{T} \Vert \vect{x}_k - \mathcal{F}[\vect{x}_{k-1}; \gvect{\theta}] \Vert^2_{\gmat{\Sigma}_\eta^{-1}} + \frac{1}{2}\sum_{k=1}^{T} \Vert \vect{y}_k - \mat{H}_k\vect{x}_k \Vert^2_{\gmat{\Sigma}_{\varepsilon,k}^{-1}} . \]

Strong-constraint 4D-Var is the limit \(\gmat{\Sigma}_\eta \to \mat{0}\). The control vector grows from \(D_x\) to \((T+1)D_x\), but the terms decouple in time, so the days can be evaluated in parallel.

Uncertainty from 4D-Var

4D-Var returns a mode, not a distribution. The Laplace approximation recovers a Gaussian around it, with the Gauss–Newton Hessian. Let \(\mat{F}_{k \leftarrow 0} = \partial \vect{x}_k / \partial \vect{x}_0 = \mat{F}_k \ldots \mat{F}_1\):

\[ \gmat{\Sigma}_{x_0 \mid y} \approx (\gmat{\Sigma}_x^{-1} + \sum_{k=1}^{T}\mat{F}_{k \leftarrow 0}^\top\mat{H}_k^\top\gmat{\Sigma}_{\varepsilon,k}^{-1}\mat{H}_k\mat{F}_{k \leftarrow 0})^{-1}. \]

It is never formed. Each Hessian–vector product is one tangent-linear plus one adjoint run, and Lanczos extracts its leading eigenpairs: the directions the ten maps actually constrain.

The ensemble Kalman filter: sampling the covariance

The EnKF keeps the Kalman equations and replaces \(\gmat{\Sigma}_{x,k \mid k-1}\) with a sample. Run \(N_{\mathcal{E}}\) members \(\vect{x}^{(e)}_k\), say \(N_{\mathcal{E}} = 32\), each through the full nonlinear model. Stack the scaled anomalies into \(\tilde{\mat{X}}_k \in \mathbb{R}^{D_x\times N_{\mathcal{E}}}\):

\[ \tilde{\mat{X}}_k = \frac{1}{\sqrt{N_{\mathcal{E}}-1}}[\vect{x}^{(1)}_k - \bar{\vect{x}}_k, \ldots, \vect{x}^{(N_{\mathcal{E}})}_k - \bar{\vect{x}}_k], \] \[ \gmat{\Sigma}_{x,k \mid k-1} \approx \tilde{\mat{X}}_k\tilde{\mat{X}}_k^\top . \]

The gain becomes \(\mat{G}_k = \tilde{\mat{X}}_k(\mat{H}_k\tilde{\mat{X}}_k)^\top(\mat{H}_k\tilde{\mat{X}}_k(\mat{H}_k\tilde{\mat{X}}_k)^\top + \gmat{\Sigma}_{\varepsilon,k})^{-1}\), and each member is updated with it. The matrix to invert is \(D_{y,k}\times D_{y,k}\), or \(N_{\mathcal{E}}\times N_{\mathcal{E}}\) in the ensemble-transform form. The sample covariance has rank at most \(N_{\mathcal{E}} - 1 = 31\) against \(D_x = 16\ 384\), so two fixes are always needed. Localisation multiplies the covariance elementwise by a compact taper \(\mat{C}_\text{loc}\) (for example 150 km), \(\gmat{\Sigma}_{x,k \mid k-1} \approx \mat{C}_\text{loc} \odot \tilde{\mat{X}}\tilde{\mat{X}}^\top\), which removes spurious long-range correlations. Inflation scales the anomalies by \(\alpha_\text{infl} \gtrsim 1\) to stop the spread collapsing. Parameters are estimated by appending \(\gvect{\theta}\) to each member's state. The ensemble smoother (EnKS) gives the reanalysis.

Cost of the oracle

MethodModel work per 10-day windowWhat you get
4D-Varn_iter × (forward + adjoint) × 480 stepsMAP trajectory, Hessian for error bars
EnKF / EnKS\(N_{\mathcal{E}}\) × 480 steps + \(T\) analysesensemble mean and spread

With 50 iterations, 4D-Var costs about 50 × 3 × 480 ≈ 7 × 10⁴ QG steps. At 128² that is seconds to minutes on one GPU. The "hours" on the figure describe a realistic primitive-equation model at 1/12°, where one model day can take minutes on hundreds of cores. For this example, step 2 is cheap enough to be the oracle for everything to its right.

Step 3

Emulator: replace 𝓕 by a network

Operatortrain_emulatorbox 3 · once, offline
Inputs
trajectories \(\vect{x}_{0:T}\)from box 1
(sim, time, height, width)
parameters \(\gvect{\theta}\), controls \(\vect{u}\)one set per trajectory
(sim, param), (sim, time, height, width)
↓
minimise the rollout loss over \(\gvect{\theta}_\text{emu}\)
Outputs
\(\gvect{\theta}_\text{emu}\)network weights
—
Operatoremulate_daybox 3 · called inside boxes 1 or 4
Inputs
\(\vect{x}_k\)today's SSH
(height, width)
\(\vect{u}^\text{cov}\), \(\vect{u}_k\)covariates, today's forcing
(height, width) each
\(\gvect{\theta}\)physics
(param,)
\(\gvect{\theta}_\text{emu}\)trained weights
—
↓
\(\mathcal{F}[\vect{x}_k, \vect{u}^\text{cov}, \vect{u}_k, \gvect{\theta}; \gvect{\theta}_\text{emu}]\), one network evaluation
Outputs
\(\vect{x}_{k+1}\)tomorrow's SSH
(height, width)
Same signature as step_day in box 1, plus weights. That is what makes it a drop-in replacement for 𝓕.

Look at where the cost of step 2 lives: in \(\mathcal{F}\), evaluated hundreds of times per iteration, and in its adjoint. Step 3 replaces that one object. A network \(\mathcal{F}[\cdot; \gvect{\theta}_\text{emu}]\) with weights \(\gvect{\theta}_\text{emu}\) learns the one-day map from the step-1 nature runs:

\[ \gvect{\theta}_\text{emu}^\star = \mathrm{argmin}_{\gvect{\theta}_\text{emu}} \mathbb{E}_{\gvect{\theta}, \vect{x}_0\sim p}\sum_{k=1}^{T} \Vert \vect{x}_k - \mathcal{F}_{k \leftarrow 0}[\vect{x}_0, \gvect{\theta}; \gvect{\theta}_\text{emu}] \Vert^2, \] \[ \vect{x}_k = \mathcal{F}_{k \leftarrow 0}[\vect{x}_0; \gvect{\theta}]. \]

With \(T = 1\) this is one-step training; with \(T > 1\) it is a rollout loss (somax's rollout_loss on a scanned cycle). Its reference is step 1: the emulator is scored against the simulator it replaces, not against data.

The figure's "skip if cheap" applies here. One emulator call replaces 48 QG steps, but 48 QG steps at 128² already take milliseconds in JAX, and the QG model is already differentiable. For this running example the emulator buys little. It pays off when \(\mathcal{F}\) is a large ocean model with no adjoint.

What the formulation demands of the emulator

4D-Var uses the adjoint \(\mat{F}_k^\top\), not only \(\mathcal{F}\). A network that matches the forward map can still have the wrong Jacobian, and then the gradients in step 4 point the wrong way. The objective above only constrains values. If the emulator is meant for step 4, add a Jacobian term with random directions \(\vect{v}\): \( \Vert \mat{F}_k\vect{v} - \hat{\mat{F}}_k\vect{v} \Vert^2\), two JVPs per sample. An ensemble method in step 4 needs forward fidelity only.

Step 4

Emulator-based inference: same cost, new model

Operatorassimilate_windowbox 4 · same signature as box 2
Inputs
\(\vect{y}_{1:T}\), \(\vect{m}_{1:T}\), \(\gvect{\sigma}_{\varepsilon,1:T}\)maps, masks, errors
3 × (time, height, width)
\(\gvect{\mu}_x\), \(\vect{u}^\text{cov}\), \(\vect{u}_{1:T}\)background, covariates, controls
(height, width), …
\(\gvect{\alpha}\) hyperparametersthe same prior and error scales as box 2
(hyper,)
\(\gvect{\theta}_\text{emu}\)the emulator from box 3
—
↓
minimise \(\mathcal{J}(\cdot; \gvect{\theta}_\text{emu})\): box 2 with \(\mathcal{F}[\cdot; \gvect{\theta}_\text{emu}]\) in place of \(\mathcal{F}\)
Outputs
\(\hat{\vect{x}}^\text{emu}_{0:T}\) analysed statescompared with box 2's on the same window
(time, height, width)
\(\hat{\gvect{\theta}}^\text{emu}\)if estimated
(param,)

Step 4 is step 2 with one substitution, \(\mathcal{F}[\cdot; \gvect{\theta}] \to \mathcal{F}[\cdot; \gvect{\theta}_\text{emu}]\). Nothing else changes: same \(\gmat{\Sigma}_x\), \(\gmat{\Sigma}_{\varepsilon,k}\), \(\mat{H}_k\), same optimiser or ensemble.

\[ \mathcal{J}(\vect{x}_0, \gvect{\theta}; \gvect{\theta}_\text{emu}) = \frac{1}{2} \Vert \vect{x}_0 - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}} + \frac{1}{2} \Vert \gvect{\theta} - \gvect{\mu}_\theta \Vert^2_{\gmat{\Sigma}_\theta^{-1}} + \frac{1}{2}\sum_{k=1}^{T} \Vert \vect{y}_k - \mat{H}_k\mathcal{F}_{k \leftarrow 0}[\vect{x}_0, \gvect{\theta}; \gvect{\theta}_\text{emu}] \Vert^2_{\gmat{\Sigma}_{\varepsilon,k}^{-1}} . \]

Its reference is step 2: the emulator-based analysis \(\hat{\vect{x}}^\text{emu}_{0:T}\) is compared with the classical analysis \(\hat{\vect{x}}_{0:T}\) on the same windows and the same maps, and in an OSSE both are compared with \(\vect{x}^\text{true}\). Each query still solves an optimisation, so step 4 still needs the observations, the prior and a solver at run time. That is the difference from step 5.

Step 5

Amortized inference: learn the inverse

Operatortrain_inversebox 5 · once, offline
Inputs
maps, masks, errorssynthetic, from box 1
(sim, time, height, width) × 3
true states \(\vect{x}_{0:T}\)the targets
(sim, time, height, width)
↓
minimise \(\mathbb{E}[-\log q(\vect{x} \mid \vect{y}; \gvect{\nu})]\) over \(\gvect{\nu}\)
Outputs
\(\gvect{\nu}\)network weights
—
Operatoranalyse_windowbox 5 · one forward pass per real window
Inputs
\(\vect{y}_{1:T}\), \(\vect{m}_{1:T}\), \(\gvect{\sigma}_{\varepsilon,1:T}\)maps, masks, errors
3 × (time, height, width)
\(\vect{u}^\text{cov}\), \(\vect{u}_{1:T}\)covariates and forcing, if the network conditions on them
(time, height, width)
\(\gvect{\nu}\)variational parameters
—
↓
\(q(\cdot \mid \vect{y}; \gvect{\nu})\): its mean \(\gvect{\mu}_{x \mid y}(\vect{y}; \gvect{\nu})\), or samples
Outputs
\(\hat{\vect{x}}_{0:T}\) posterior meanthe direct analysis
(time, height, width)
posterior samplesonly if q is a full density (a flow)
(member, time, height, width)
Same outputs as box 2, but no μ_x and no α among the inputs: the prior is learned from the training set, not passed in.

Steps 2 and 4 solve a new optimisation for every window. Step 5 learns the answer as a function of the data. A conditional density \(q(\cdot; \gvect{\nu})\) with weights \(\gvect{\nu}\) is trained on step-1 OSSE pairs:

\[ \gvect{\nu}^\star = \mathrm{argmin}_{\gvect{\nu}} \mathbb{E}_{(\vect{x}_{0:T}, \vect{y}_{1:T})\sim p}[-\log q(\vect{x}_{0:T} \mid \vect{y}_{1:T}; \gvect{\nu})], \] \[ q(\vect{x}_{0:T} \mid \vect{y}_{1:T}; \gvect{\nu}^\star) \approx p(\vect{x}_{0:T} \mid \vect{y}_{1:T}). \]

Minimising this expectation minimises \(\mathbb{E}_{\vect{y}}[\mathrm{KL}[p(\cdot \mid \vect{y}) \Vert q(\cdot \mid \vect{y}; \gvect{\nu})]]\), so a flexible \(q(\cdot; \gvect{\nu})\) converges to the true posterior. The simplest case is a Gaussian with fixed variance and learned mean \(\gvect{\mu}_{x \mid y}(\vect{y}; \gvect{\nu})\). The loss becomes mean squared error, whose minimiser is the posterior mean:

\[ \min_{\gvect{\nu}} \mathbb{E} \Vert \vect{x}_{0:T} - \gvect{\mu}_{x \mid y}(\vect{y}_{1:T}; \gvect{\nu}) \Vert^2 \Longrightarrow \gvect{\mu}_{x \mid y}(\vect{y}_{1:T}; \gvect{\nu}^\star) = \mathbb{E}[\vect{x}_{0:T} \mid \vect{y}_{1:T}]. \]

That is the "direct analysis": a network from ten SSH maps to the state, at the cost of one forward pass.

Three consequences of the formulation

Mean versus mode. An MSE-trained network estimates the posterior mean, while 4D-Var returns the posterior mode. They coincide only in the linear-Gaussian case. Compare step 5 with the step-2 ensemble mean, or with 4D-Var's mode only after checking the posterior is close to Gaussian.

The prior is implicit. Steps 2 and 4 state \(\gmat{\Sigma}_x\) and \(p(\gvect{\theta})\) explicitly. Step 5 learns its prior from the distribution of nature runs. If those runs do not look like the real Gulf Stream, the amortized posterior is wrong in a way no single-window check reveals.

The observing system is part of the input. \(\mat{H}_k\) and \(\gmat{\Sigma}_{\varepsilon,k}\) change every day (clouds do not matter for altimetry, but coverage, land masks and pixel errors do). The network must see the mask and error map as inputs, \(q(\vect{x} \mid \vect{y},\mat{H},\gmat{\Sigma}_\varepsilon; \gvect{\nu})\), and training must sample them, or it will fail on the first unusual day.

Step 6

Improve: the misfit decides which loop runs

Operatordiagnosebox 6 · over W windows of any inverse box
Inputs
innovations \(\vect{d}\)maps minus forecast, at observed pixels
(window, time, height, width)
analyses from boxes 2, 4, 5plus their references
(window, time, height, width) each
\(\vect{x}^\text{true}\)only in an OSSE
(window, time, height, width)
↓
innovation mean and covariance, \(2\mathcal{J}^\star/D_y\), errors of each box against its reference
Outputs
mean innovation \(\bar{\vect{d}}\)a bias map: structure here means a structural error
(height, width)
\(2\mathcal{J}^\star/D_y\) per windowshould hover near 1
(window,)
scores per boxe.g. RMSE against the reference, per day
(box, time)
which loopinner (x, θ) or outer (𝓕, H, α)
1 of 2

The formulation contains its own test. If steps 1 and 2 are right, the innovations, \(\vect{d}_k = \vect{y}_k - \mat{H}_k\vect{x}_{k \mid k-1}\), must be zero-mean with a known covariance, because they are the sum of two independent Gaussian errors:

\[ \mathbb{E}[\vect{d}_k] = \vect{0}, \] \[ \mathbb{E}[\vect{d}_k\vect{d}_k^\top] = \mat{H}_k\gmat{\Sigma}_{x,k \mid k-1}\mat{H}_k^\top + \gmat{\Sigma}_{\varepsilon,k} . \]

Two scalar checks follow. With \(D_y = \sum_k D_{y,k}\) observations, the minimum of a correctly specified cost satisfies \(\mathbb{E}[2 \mathcal{J}(\vect{x}^\star)]\approx D_y\), so \(2\mathcal{J}^\star/D_y\) should be near 1. These checks are how the hyperparameters \(\gvect{\alpha}\) get tuned. The Desroziers identity checks \(\gmat{\Sigma}_\varepsilon\) on its own: \(\mathbb{E}[(\vect{y}_k - \mat{H}_k \vect{x}_{k \mid k})(\vect{y}_k - \mat{H}_k \vect{x}_{k \mid k-1})^\top] \approx \gmat{\Sigma}_{\varepsilon,k}\).

MisfitSignatureLoopWhat changes
Random\(\bar{\vect{d}}\approx\vect{0}\), \(2\mathcal{J}^\star/D_y\approx 1\)inner\(\vect{x}\) and \(\gvect{\theta}\) only: rerun step 2
Systematicmean innovation with spatial structure, \(2\mathcal{J}^\star/D_y\gg 1\)outerstructure: \(\mathcal{F}\) (1.5 → 2 layers, wind forcing, topography), \(\mat{H}\) (mean dynamic topography), the forms of \(\gmat{\Sigma}_\varepsilon\) and \(\gmat{\Sigma}_x\) (correlations), or just \(\gvect{\alpha}\)

Each box downstream is improved against its reference. The formulation fixes those references precisely, because each step approximates one object defined earlier:

ImproveIt approximatesReference
Step 2\(p(\vect{x} \mid \vect{y})\) under the step-1 modelstep 1: \(\vect{x}^\text{true}\) in an OSSE
Step 3\(\mathcal{F}\)step 1: the simulator
Step 4step 2's posterior, with \(\mathcal{F}[\cdot; \gvect{\theta}_\text{emu}]\)step 2
Step 5step 2's posterior, without an inversionstep 2 (step 4 as a cheaper stand-in)
A mismatch in the figure

The six-step figure's bottom row lists the ground truth for step 3 as step 2, and for steps 4 and 5 as the step before each. The formulation says step 3's reference is step 1 (an emulator approximates \(\mathcal{F}\), not a posterior), and steps 4 and 5 are both scored against the step-2 posterior. The axes-matrix figure in the same overview already says this ("step 3 scored against step 1; steps 4 and 5 scored against step 2"). The bottom row of six_steps.svg should match it.


The six boxes, read from the formulation

BoxMathematical objectFor the Gulf Stream exampleIn the stack
1 Simple model\(p(\gvect{\theta})p(\vect{x}_0)\prod_k p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}) p(\vect{y}_k \mid \vect{x}_k)\)1.5-layer QG, daily SSH maps, OSSEssomax (QG), finitevolX (Arakawa Jacobian, Helmholtz)
2 Model-based inference\(p(\vect{x}_{0:T},\gvect{\theta} \mid \vect{y}_{1:T})\) via \(\mathrm{argmin}\mathcal{J}\) or EnKF10-day 4D-Var or EnKS reanalysisDA cycle with classical analysis, gaussx for \(\gmat{\Sigma}_x^{1/2}\)
3 Emulator\(\mathcal{F}[\cdot; \gvect{\theta}_\text{emu}] \approx \mathcal{F}[\cdot; \gvect{\theta}]\)learned one-day steptraining on simulated trajectories
4 Emulator-based inference\(\mathrm{argmin}\mathcal{J}(\cdot; \gvect{\theta}_\text{emu})\)same 4D-Var, emulator insideDA cycle with a neural forward model
5 Amortized inference\(q(\vect{x} \mid \vect{y}; \gvect{\nu})\approx p(\vect{x} \mid \vect{y})\)maps → state in one passconditional flows or a Gaussian head
6 Improve\(\mathbb{E}[\vect{d}_k \vect{d}_k^\top]\) vs \(\mat{H}_k \gmat{\Sigma}_{x,k \mid k-1} \mat{H}_k^\top + \gmat{\Sigma}_{\varepsilon,k}\)innovation and \(\chi^2\) diagnosticsbenchmarks rerun against their references

Moving right, the work moves from query time to training time. Step 2 pays the model and its adjoint on every window. Step 4 pays a cheaper model on every window. Step 5 pays once, during training, and then each window costs one network evaluation.