GeoStack · vardax · primer

Weak-constraint 4D-Var: when the model is allowed to be wrong

Strong-constraint 4D-Var treats the model as exact and corrects only the initial state. Weak-constraint 4D-Var admits that the model makes errors, and estimates them alongside the state. This page follows the 4D-Var primer: the intuition, the probability, the mathematics, how the gradient changes, three formulations, and how it maps onto the ocean problem.

TL;DR

Same cost, plus a price for every model error

Intuition

In the 4D-Var primer's mechanical picture the model was a rigid rod: the trajectory had to be an exact model solution, so every misfit had to be blamed on the initial state. Weak constraint cuts the rod into one segment per observation interval and joins the segments with springs of stiffness \(\gmat{\Sigma}_\eta^{-1}\). The trajectory may now jump at the end of each interval, at a price.

The running example is Lorenz-96 again, now with a deliberately wrong model: the truth uses forcing \(F = 8\), the model \(F = 7\). The window is 20 observation times (twice the primer's), every second site is observed with \(\sigma_\varepsilon = 0.5\), and the background is as before.

site 1 · observed · model forcing F = 7, truth F = 8012truthstrongweak (jumps η_k)dots: obs
An observed site. The strong-constraint trajectory (dashed) is a solution of the wrong model and drifts away from the observations late in the window. The weak-constraint trajectory jumps a little at every observation time and follows the truth.
Lorenz-96, biased modelRMSE over the windowat \(t = 0\)end of window\(2\mathcal{J}^\star / N_\text{obs}\)
background3.391.013.76—
strong constraint1.660.592.397.6
weak, forcing formulation, \(\sigma_\eta = 0.3\)0.250.330.39—
weak, reduced: one constant bias0.120.160.190.98

The last column is the strong-constraint primer's consistency check, with \(N_\text{obs} = 400\) observations. A value of 7.6 instead of about 1 is the classic signature of model error: the data cannot be fitted by any model trajectory.

Where it comes from

On the six-step page's terms, only the transition density changes. Strong constraint used a point mass; weak constraint uses a Gaussian around the model forecast:

\[ p(\vect{x}_0) = \mathcal{N}(\gvect{\mu}_x, \gmat{\Sigma}_x), \] \[ p(\vect{x}_k \mid \vect{x}_{k-1}) = \mathcal{N}(f_k(\vect{x}_{k-1}), \gmat{\Sigma}_{\eta,k}), \] \[ p(\vect{y}_k \mid \vect{x}_k) = \mathcal{N}(h_k(\vect{x}_k), \gmat{\Sigma}_{\varepsilon,k}). \]

The posterior is now over the whole trajectory, \(p(\vect{x}_{0:T} \mid \vect{y}_{1:T})\), and weak-constraint 4D-Var returns its mode. That is the smoothing problem: for a linear model the answer equals the Kalman smoother's mean. The two limits are worth keeping in mind. As \(\gmat{\Sigma}_\eta \to \mat{0}\) the jumps are forbidden and strong constraint returns. As \(\gmat{\Sigma}_\eta \to \infty\) consecutive states decouple and the method becomes a separate 3D-Var at each observation time, ignoring the dynamics.

The mathematics

Notation

SymbolMeaningShapeUnits (ocean)
\(\vect{x}_k = \vect{x}(t_k)\)state at observation time \(t_k\), \(k = 0, \ldots, T\)\(\mathbb{R}^{D_x}\) (state)m (SSH) or s⁻¹ (PV)
\(\gvect{\eta}_k\)model error over interval \(k\): what the model missed between \(t_{k-1}\) and \(t_k\)\(\mathbb{R}^{D_x}\)state units per interval
\(\gmat{\Sigma}_{\eta,k}\), \(\gmat{\Sigma}_\eta\)model-error covariance (per interval; when it is the same for every interval)\(\mathbb{R}^{D_x \times D_x}\)(state units per interval)²
\(\vect{w}_k\)whitened model error, \(\gvect{\eta}_k = \gmat{\Sigma}_\eta^{1/2}\vect{w}_k\)\(\mathbb{R}^{D_x}\)dimensionless
\(\vect{y}_k, \gvect{\mu}_x, \gmat{\Sigma}_x, \gmat{\Sigma}_{\varepsilon,k}, f_k, \mat{F}_k, h_k, \mat{H}_k, \gvect{\xi}, \vect{x}^\dagger_k\)as in the 4D-Var primer—
\(\varphi_p(\vect{s}), \chi_c(t), \gvect{\omega}, \gmat{\Sigma}_\omega\)spatial and temporal model-error bases (\(p = 1, \ldots, N_\varphi\), \(c = 1, \ldots, N_\chi\)), coefficients \(\omega_{cp}\), their diagonal prior covariance\(\mathbb{R}^{D_x \times N_\varphi}\) and \(\mathbb{R}^{T \times N_\chi}\) on the grid and times, \(\mathbb{R}^{N_\chi N_\varphi}\) (temporal, basis), \(\mathbb{R}^{N_\chi N_\varphi \times N_\chi N_\varphi}\)—
\(\vect{w}_\omega\), \(\gmat{\Phi}\)whitened coefficients, \(\gvect{\omega} = \gmat{\Sigma}_\omega^{1/2}\vect{w}_\omega\); space–time basis matrix with entries \(\chi_c(t_k)\varphi_p(\vect{s}_n)\) at grid points \(\vect{s}_n\)\(\mathbb{R}^{N_\chi N_\varphi}\), \(\mathbb{R}^{T D_x \times N_\chi N_\varphi}\)—

The cost, in two equivalent forms

With the model errors as controls (the forcing formulation):

\[ \mathcal{J}(\vect{x}_0, \gvect{\eta}_{1: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 \gvect{\eta}_k \Vert^2_{\gmat{\Sigma}_{\eta,k}^{-1}} + \frac{1}{2} \sum_{k=1}^{T} \Vert \vect{y}_k - h_k(\vect{x}_k) \Vert^2_{\gmat{\Sigma}_{\varepsilon,k}^{-1}}, \] \[ \vect{x}_k = f_k(\vect{x}_{k-1}) + \gvect{\eta}_k. \]

With the states as controls (the state formulation), substitute \(\gvect{\eta}_k = \vect{x}_k - f_k(\vect{x}_{k-1})\):

\[ \mathcal{J}(\vect{x}_0, \vect{x}_{1: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 - f_k(\vect{x}_{k-1}) \Vert^2_{\gmat{\Sigma}_{\eta,k}^{-1}} + \frac{1}{2} \sum_{k=1}^{T} \Vert \vect{y}_k - h_k(\vect{x}_k) \Vert^2_{\gmat{\Sigma}_{\varepsilon,k}^{-1}}. \]

The map between the two sets of controls is one-to-one, so the two costs have the same minima. They are different optimisation problems all the same: different conditioning, different paths, and, for a nonlinear model, possibly different local minima from the same start.

Useweak-constraint 4D-Var analysisone window, imperfect model
In
\(\vect{y}_{1:T}\) observationsobserved-field units (m for SSH)
(time, obs)
\(\vect{m}_{1:T}\)which values are observed
(time, obs)
\(\gvect{\mu}_x\)background of \(\vect{x}_0\); state units
(state,)
\(\gmat{\Sigma}_x\)state units²
(state, state)
\(\gmat{\Sigma}_\varepsilon\)observation units²
(obs, obs) per time
\(\gmat{\Sigma}_\eta\) model-error covariance(state units per interval)²
(state, state)
\(f_k\) modelone observation interval, known to be imperfect
(state,) → (state,)
↓
\(\min \frac{1}{2} \Vert \vect{x}_0 - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}} + \frac{1}{2} \sum_k \Vert \gvect{\eta}_k \Vert^2_{\gmat{\Sigma}_\eta^{-1}} + \frac{1}{2} \sum_k \Vert \vect{y}_k - h_k(\vect{x}_k) \Vert^2_{\gmat{\Sigma}_\varepsilon^{-1}}\), with \(\vect{x}_k = f_k(\vect{x}_{k-1}) + \gvect{\eta}_k\)
Out
\(\vect{x}_{0:T}^\star\) analysed trajectoryno longer a pure model run; state units
(time+1, state)
\(\gvect{\eta}_{1:T}^\star\) model-error estimatestate units per interval
(time, state)
\(\mathcal{J}^\star\)dimensionless
()
The extra output is the point: \(\gvect{\eta}^\star\) says where and when the model was wrong.
# Shapes: N = 40 state values (state); T = 20 observation times (time);
#         M = 20 observed values per time (obs)


def weak_cost(control, args):
    """Forcing formulation: control (ξ, w₁:T), with η_k = Σ_η^{1/2} w_k."""
    whitened_initial, whitened_errors = control       # (N,), (T, N)
    forward, background, prior_sqrt, error_sqrt, observations, obs_index, obs_std = args
    # x₀ = μ_x + Σ_x^{1/2} ξ
    initial = background + prior_sqrt.mv(whitened_initial)  # (N,)

    def interval(state, whitened_error):
        # user (forward.step: one observation interval)
        # f_k(x_{k−1})
        forecast = forward.step(state, forward.dt)    # (N,) → (N,)
        # η_k = Σ_η^{1/2} w_k
        model_error = error_sqrt.mv(whitened_error)   # (N,)
        # x_k = f_k(x_{k−1}) + η_k
        state = forecast + model_error                # (N,)
        return state, state

    # x₁:T, one model run with a kick after every interval
    _, states = jax.lax.scan(interval, initial, whitened_errors)  # → (T, N)
    # r_k = Σ_ε^{−1/2} (y_k − H_k x_k)
    residual = (observations - states[:, obs_index]) / obs_std  # (T, M)
    # 𝒥_o = ½ ∑_k ‖r_k‖²
    obs_cost = 0.5 * einx.sum("time obs ->", residual**2)  # → ()
    # 𝒥_b = ½ ‖ξ‖²
    background_cost = 0.5 * einx.sum("state ->", whitened_initial**2)  # → ()
    # 𝒥_η = ½ ∑_k ‖η_k‖²_{Σ_η⁻¹} = ½ ∑_k ‖w_k‖²
    error_cost = 0.5 * einx.sum("time state ->", whitened_errors**2)  # → ()
    return background_cost + error_cost + obs_cost

Choosing \(\gmat{\Sigma}_\eta\)

The gradient

Forcing formulation. The adjoint recursion is the strong-constraint one, run on the kicked trajectory. Because \(\gvect{\eta}_k\) enters exactly where \(\vect{x}_k\) does, its gradient comes free from the same sweep:

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

State formulation. Each term couples only neighbouring states, so the gradient with respect to \(\vect{x}_k\) needs only intervals \(k\) and \(k+1\): \(\nabla_{\vect{x}_k}\mathcal{J} = \vect{a}_k - \mat{F}_{k+1}^\top \vect{a}_{k+1} + \nabla_{\vect{x}_k}\mathcal{J}_{\text{o},k}\), with \(\vect{a}_k = \gmat{\Sigma}_{\eta,k}^{-1}(\vect{x}_k - f_k(\vect{x}_{k-1}))\). All \(T\) model runs and adjoints are independent. Everything on the primer's gradient page still applies inside each interval: checkpointing, diffrax adjoints, the adjoint test.

# Shapes: N state values (state); T observation times (time)

# --- forcing formulation: one backward sweep gives every gradient ---
# x†_k = ∇_{x_k} 𝒥_{o,k} + F_{k+1}ᵀ x†_{k+1}, as in strong-constraint 4D-Var


def backward(carry, inputs):
    state_prev, obs_forcing = inputs
    # x†_k = ∇_{x_k} 𝒥_{o,k} + F_{k+1}ᵀ x†_{k+1}
    lam = obs_forcing + carry                         # (N,)
    _, interval_vjp = jax.vjp(lambda s: forward.step(s, forward.dt), state_prev)
    # F_kᵀ x†_k, handed to the previous time
    (carry,) = interval_vjp(lam)                      # (N,) → (N,)
    return carry, lam


grad_initial, lams = jax.lax.scan(                    # → (N,), (T, N)
    backward, jnp.zeros(N), (previous, obs_forcing), reverse=True
)
# ∇_{w_k} 𝒥 = w_k + (Σ_η^{1/2})ᵀ x†_k: the kick at time k feels the same x†_k as x_k
grad_errors = whitened_errors + jax.vmap(error_sqrt.T.mv)(lams)  # (T, N)
# ∇_ξ 𝒥 = ξ + (Σ_x^{1/2})ᵀ F_1ᵀ x†_1
grad_initial = whitened_initial + prior_sqrt.T.mv(grad_initial)  # (N,)

# --- state formulation: every interval differentiated at once ---
# f_k(x_{k−1}) for k = 1…T, all in parallel
forecasts, interval_vjps = jax.vmap(                  # (T, N) → (T, N)
    lambda s: jax.vjp(lambda u: forward.step(u, forward.dt), s)
)(previous)
# a_k = Σ_η⁻¹ (x_k − f_k(x_{k−1}))
scaled = jax.vmap(error_cov_inv.mv)(states - forecasts)  # (T, N)
# ∇_{x_k} 𝒥 ∋ a_k − F_{k+1}ᵀ a_{k+1} + ∇_{x_k} 𝒥_{o,k}: local in time, no sweep
(pulled,) = jax.vmap(lambda f, a: f(a))(interval_vjps, scaled)  # (T, N)
Gradient on Lorenz-96 (20 intervals, CPU)TimeWorking memory
forcing form (sequential scan)1.68 ms542 KB
state form (vmap over intervals)0.64 ms625 KB

Even on one CPU the state formulation's gradient is about 2.6 times faster, because the 20 intervals run as one batched call instead of a chain. On several devices the gap grows with \(T\).

Formulation 1 · Model errors as controls (forcing)

per iteration one forward run with kicks + one adjoint run

Idea. Keep the strong-constraint machinery and add a kick \(\gvect{\eta}_k\) after every interval. Whitening both \(\vect{x}_0\) and \(\gvect{\eta}_k\) makes every prior term \(\frac{1}{2} \Vert \cdot \Vert^2\). This is what vardax's WeakFourDVar implements.

Useforcing formulationcontrol (ξ, w₁:T), sequential in time
In
\(\gvect{\xi}\)whitened initial increment
(state,)
\(\vect{w}_{1:T}\)whitened model errors, \(\gvect{\eta}_k = \gmat{\Sigma}_\eta^{1/2}\vect{w}_k\)
(time, state)
\(\vect{y}_{1:T}\) observationsobserved-field units (m for SSH)
(time, obs)
\(\gvect{\mu}_x\)background of \(\vect{x}_0\); state units
(state,)
↓
one forward run with a kick \(\gvect{\eta}_k\) after every interval; one adjoint sweep gives \(\nabla_{\gvect{\xi}}\mathcal{J}\) and every \(\nabla_{\vect{w}_k}\mathcal{J}\)
Out
\(\mathcal{J}\), \(\nabla_{(\gvect{\xi}, \vect{w}_{1:T})}\mathcal{J}\)dimensionless
(), (state,) + (time, state)
Lorenz-96 (\(\sigma_\eta = 0.3\)): 694 cost-and-gradient evaluations over 840 controls.
# Shapes: N state values (state); T observation times (time)

# not pinned (optimistix)
# (ξ⋆, w⋆) = argmin 𝒥(ξ, w): L-BFGS over N(T+1) controls
solution = optx.minimise(                             # → ((N,), (T, N))
    weak_cost,
    optx.BFGS(rtol=1e-6, atol=1e-6),
    (jnp.zeros(N), jnp.zeros((T, N))),
    args=args,
    max_steps=3000,
)
whitened_initial, whitened_errors = solution.value
# η_k⋆ = Σ_η^{1/2} w_k⋆: the model-error estimate, one field per interval
model_errors = jax.vmap(error_sqrt.mv)(whitened_errors)  # (T, N)

# --- the same through vardax ---
# today (vardax)
# control (x₀, η₁:T) in physical units; Σ_η⁻¹ applied by a linear solve per step
weak = vardax.WeakFourDVar(
    forward,
    vardax.MaskedIdentity(),
    background,                                       # (N,)
    prior_cov,                                        # (N, N) PSD operator, Σ_x
    obs_cov,                                          # (N, N) PSD operator, Σ_ε
    model_err_cov,                                    # (N, N) PSD operator, Σ_η
)
# today (vardax)
analysis_initial, model_errors = weak(batch)          # → (1, N), (1, T, N)

The size of \(\gmat{\Sigma}_\eta = \sigma_\eta^2 \mat{I}\) decides everything. Sweeping \(\sigma_\eta\) on the same window:

\(\sigma_\eta\) (per interval)Evaluations\(\mathcal{J}^\star\)RMSE \(t = 0\)RMSE windowRMSE end
0.034188030.571.132.64
0.111112440.390.280.45
0.36941480.330.250.39
1.02698520.470.370.50

Too small (\(\sigma_\eta = 0.03\)) and the method behaves almost like strong constraint. Around the true error size (0.1 to 0.3) the window error falls six-fold. Too large (\(\sigma_\eta = 1\)) and the jumps start absorbing observation noise, the error rises again, and the problem becomes harder to solve (more than 2,600 evaluations). Weakness: \(D_x (T+1)\) controls, a sequential run, and a \(\gmat{\Sigma}_\eta\) that must be right in both size and structure.

Formulation 2 · States as controls (time-parallel)

per iteration T independent interval runs + their adjoints, in parallel

Idea. Optimise every state in the window directly. The model appears only as a penalty on consecutive pairs, so nothing is run end to end, and all intervals can be computed at once. This is the basis of the time-parallel weak-constraint systems in operational development, usually inside a Gauss–Newton loop with a specially preconditioned inner solve (the saddle-point formulation).

Usestate formulationcontrol (ξ, x₁:T), parallel in time
In
\(\gvect{\xi}\)whitened initial increment
(state,)
\(\vect{x}_{1:T}\)every state in the window is a control; state units
(time, state)
\(\vect{y}_{1:T}\) observationsobserved-field units (m for SSH)
(time, obs)
\(\gvect{\mu}_x\)background of \(\vect{x}_0\); state units
(state,)
↓
\(f_k(\vect{x}_{k-1})\) for all \(k\) at once; mismatches \(\vect{x}_k - f_k(\vect{x}_{k-1})\) scored against \(\gmat{\Sigma}_\eta\)
Out
\(\mathcal{J}\), \(\nabla_{(\gvect{\xi}, \vect{x}_{1:T})}\mathcal{J}\)dimensionless
(), (state,) + (time, state)
No sequential sweep: the T model intervals and their adjoints can run on T devices.
# Shapes: N state values (state); T observation times (time); M observed values (obs)


def state_cost(control, args):
    """State formulation: control (ξ, x₁:T); the model only scores consecutive pairs."""
    whitened_initial, states = control                # (N,), (T, N)
    forward, background, prior_sqrt, error_std, observations, obs_index, obs_std = args
    # x₀ = μ_x + Σ_x^{1/2} ξ
    initial = background + prior_sqrt.mv(whitened_initial)  # (N,)
    # (x₀, …, x_{T−1}): the start of every interval
    previous = einx.rearrange(                        # (N,), (T, N) → (T, N)
        "state, time state -> (1 + time) state", initial, states
    )[:-1]
    # f_k(x_{k−1}) for all k at once: no sequential dependence
    forecasts = jax.vmap(lambda s: forward.step(s, forward.dt))(previous)  # (T, N)
    # Σ_η^{−1/2} (x_k − f_k(x_{k−1})), diagonal Σ_η = σ_η² I
    mismatch = (states - forecasts) / error_std       # (T, N)
    # 𝒥_η = ½ ∑_k ‖x_k − f_k(x_{k−1})‖²_{Σ_η⁻¹}
    error_cost = 0.5 * einx.sum("time state ->", mismatch**2)  # → ()
    # r_k = Σ_ε^{−1/2} (y_k − H_k x_k)
    residual = (observations - states[:, obs_index]) / obs_std  # (T, M)
    obs_cost = 0.5 * einx.sum("time obs ->", residual**2)  # → ()
    background_cost = 0.5 * einx.sum("state ->", whitened_initial**2)  # → ()
    return background_cost + error_cost + obs_cost


# start from the background trajectory, which makes every mismatch zero
initial_states = rollout(forward, background, T)      # → (T, N)

On Lorenz-96, with \(\sigma_\eta = 0.1\) and started from the background trajectory (the same starting point as the forcing run), L-BFGS converged in 298 evaluations, but to a different local minimum: \(\mathcal{J}^\star = \) 879 against 244 for the forcing formulation, with window error 1.57 against 0.28. The two problems share their minima; on a 20-interval chaotic window they do not share their paths. The lesson is practical: the state formulation needs a good starting trajectory (from a strong-constraint analysis, an ensemble smoother, or shorter overlapping windows) and the Gauss–Newton structure that makes its inner problems quadratic. Its payoff is parallel speed, not robustness.

Formulation 3 · Reduced model error

per iteration one forward run + one adjoint run, over \(D_x + N_\chi N_\varphi\) controls

Idea. Write the model error in a few basis functions in space and time, with a prior on the coefficients:

\[ \eta(\vect{s}, t_k) = \sum_{c=1}^{N_\chi} \sum_{p=1}^{N_\varphi} \omega_{cp} \chi_c(t_k) \varphi_p(\vect{s}), \] \[ \omega_{cp} \sim \mathcal{N}(0, \sigma^2_{\omega,cp}), \] \[ \gmat{\Sigma}_{\eta_{1:T}} = \gmat{\Phi} \gmat{\Sigma}_\omega \gmat{\Phi}^\top. \]

A bias is the simplest case: one temporal function \(\chi \equiv 1\), so the same error repeats every interval. Choosing the bases is choosing \(\gmat{\Sigma}_\eta\), and it puts the physics of the model error into the method: smooth wind-stress errors, a missing eddy flux at the western boundary, a seasonal drift.

Usereduced model errormodel error in a few basis coefficients
In
\(\gvect{\xi}\)whitened initial increment
(state,)
\(\vect{w}_\omega\) coefficientswhitened; \(\gvect{\omega} = \gmat{\Sigma}_\omega^{1/2}\vect{w}_\omega\)
(temporal, basis)
\(\varphi_p(\vect{s})\), \(\chi_c(t)\)spatial and temporal basis; state units per unit coefficient
(state, basis), (time, temporal)
\(\vect{y}_{1:T}\) observationsobserved-field units (m for SSH)
(time, obs)
\(\gvect{\mu}_x\)background of \(\vect{x}_0\); state units
(state,)
↓
\(\eta(\vect{s}, t_k) = \sum_{c,p} \omega_{cp} \chi_c(t_k) \varphi_p(\vect{s})\), then the forcing-formulation run
Out
\(\gvect{\omega}^\star\)the model-error pattern, e.g. a bias
(temporal, basis)
\(\vect{x}_{0:T}^\star\)analysed trajectory
(time+1, state)
The model-error covariance is implied: \(\gmat{\Sigma}_{\eta_{1:T}} = \gmat{\Phi} \gmat{\Sigma}_\omega \gmat{\Phi}^\top\). Choosing the basis is choosing \(\gmat{\Sigma}_\eta\).
# Shapes: N state values (state); T observation times (time);
#         P spatial basis functions (basis); C temporal basis functions (temporal)


def reduced_error(coeffs, spatial, temporal, scales):
    """η(s, t_k) = ∑_{c,p} ω_{cp} χ_c(t_k) φ_p(s): model error in C × P coefficients."""
    # ω = Σ_ω^{1/2} w_ω, whitened control
    weights = scales * coeffs                         # (C, P)
    # ∑_p ω_{cp} φ_p(s): one spatial field per temporal mode
    fields = einx.dot(                                # (C, P), (N, P) → (C, N)
        "temporal basis, state basis -> temporal state", weights, spatial
    )
    # η_k = ∑_c χ_c(t_k) fields_c
    return einx.dot(                                  # (T, C), (C, N) → (T, N)
        "time temporal, temporal state -> time state", temporal, fields
    )


def reduced_cost(control, args):
    whitened_initial, coeffs = control                # (N,), (C, P)
    (forward, background, prior_sqrt, spatial, temporal, scales,
     observations, obs_index, obs_std) = args
    initial = background + prior_sqrt.mv(whitened_initial)  # (N,)
    # η₁:T from C·P numbers instead of T·N
    model_errors = reduced_error(coeffs, spatial, temporal, scales)  # → (T, N)

    def interval(state, model_error):
        # x_k = f_k(x_{k−1}) + η_k
        state = forward.step(state, forward.dt) + model_error  # (N,)
        return state, state

    _, states = jax.lax.scan(interval, initial, model_errors)  # → (T, N)
    residual = (observations - states[:, obs_index]) / obs_std  # (T, M)
    obs_cost = 0.5 * einx.sum("time obs ->", residual**2)  # → ()
    # 𝒥_b = ½ ‖ξ‖²
    background_cost = 0.5 * einx.sum("state ->", whitened_initial**2)  # → ()
    # 𝒥_η = ½ ‖w_ω‖², the model-error prior, whitened
    error_cost = 0.5 * einx.sum("temporal basis ->", coeffs**2)  # (C, P) → ()
    prior_cost = background_cost + error_cost         # → ()
    return prior_cost + obs_cost


# the bias case: C = 1, χ ≡ 1 (constant in time), P = 1, φ = uniform
# then η is one number per interval, the forcing error times Δt
BasisControlsEvaluationsRecovered \(\Delta F\) (true 1.0)RMSE windowRMSE end
uniform, constant in time (\(N_\varphi = 1\))\(D_x + 1\)3350.990.120.19
5 Fourier modes, constant in time\(D_x + 5\)4660.990.120.20

Because this model error really is a constant bias, one extra number fixes it: the analysis error halves again relative to the forcing formulation, and the estimate is a physical quantity. It also improves the forecast, which the other formulations cannot do directly: running the model forward with the estimated bias gives a 10-interval forecast error of 1.50, against 2.39 without it, and 1.81 when starting from the true state with the uncorrected model. The per-interval kicks of the forcing formulation average to an implied \(\Delta F\) of only 0.39, because they also absorb random misfit, so they correct its forecast much less (2.88 against 2.99). Weakness: the method can only fix errors its basis can express.

The three side by side

ForcingStateReduced
Controls\(\vect{x}_0, \gvect{\eta}_{1:T}\): \(D_x (T+1)\)\(\vect{x}_{0:T}\): \(D_x (T+1)\)\(\vect{x}_0, \gvect{\omega}\): \(D_x + N_\chi N_\varphi\)
Model runssequential, one per iterationT in parallelsequential, one per iteration
\(\gmat{\Sigma}_\eta\)explicit, any structureexplicit, any structureimplied, \(\gmat{\Phi} \gmat{\Sigma}_\omega \gmat{\Phi}^\top\)
Strengthreuses strong-constraint code; robust startparallel in timefew controls; interpretable; corrects forecasts
Weaknessmany controls; sequentialneeds a good start; conditioningblind outside the basis
Lorenz-96 window RMSE0.25 (\(\sigma_\eta = 0.3\))1.57 (local minimum)0.12
vardaxWeakFourDVarnot availablenot available (docs/07: "not provided out of the box")

Application: the Gulf Stream window

The QG model of the other pages is a good candidate for weak constraint: it omits unresolved eddy fluxes, uses an imperfect wind forcing, and has no topography.

Useweak-constraint 4D-Var on the Gulf Stream windowthe ocean state estimation page's setting
In
\(\vect{y}_{1:T}\) SSH mapsm; σ_ε ≈ 3 cm along-track
(time, height, width)
\(\gmat{\Sigma}_\eta\)PV-tendency error × 1 day, s⁻¹ per day; or a wind-stress-curl error basis
(cell, cell)
\(f_k\)1.5-layer QG, 1 day = 48 × 30 min, missing eddy fluxes and wind errors
(height, width) → (height, width)
↓
\(T = 10\) days × \(D_x = 16384\): 180,224 controls in the forcing formulation; a few hundred with a reduced basis
Out
\(\vect{x}_{0:T}^\star\)reanalysis that can depart from the QG dynamics
(time+1, height, width)
\(\gvect{\eta}^\star\)where the QG model needed help
(time, height, width)
ChoiceAt ocean scale
Forcing formulation180,224 controls for a 10-day window; feasible with checkpointed adjoints, but \(\gmat{\Sigma}_\eta\) must be smooth (a Matérn kernel or a diffusion-operator smoother) or the kicks fill with grid-scale noise
State formulationone day per device; worth it for long windows, started from a strong-constraint or ensemble-smoother trajectory
Reduced model errorthe natural fit: a wind-stress-curl error in a few spatial modes with daily or weekly temporal windows, priors from the SSH spectrum (\(\sigma_{\omega,a}^2 \propto \kappa_a^{-4}\), with \(\kappa_a\) the wavenumber of mode \(a\)), as in somax's forcing-basis design note
Diagnostics\(2\mathcal{J}^\star / N_\text{obs}\) for the strong analysis first: a value far above 1 is the argument for weak constraint; then the mean of \(\gvect{\eta}^\star\) over many windows as a model-bias map

Notes on vardax

Checked against vardax main (3e0f34a), alongside issues #94–#99 filed from the 4D-Var primer.

Code markers: today (vardax) names ship on vardax main (3e0f34a); not pinned marks optimistix. Lorenz-96 numbers come from a JAX run in double precision: truth F = 8, model F = 7, D_x = 40, 20 observation times of 10 RK4 steps, every second site observed with σ_ε = 0.5, Σ_x as in the primer.