Assimilation¶
3D/4D-Var cost, control, and solve scaffolding (optimistix), imported on demand.
The package resolves its public names lazily (PEP 562), so they are documented here from the submodules that define them.
assimilation
¶
Variational data assimilation (3D-Var) for methane VMR retrieval.
Modules¶
- :mod:
.obs_operator— JAX forward operatorH = SRF ∘ GSD ∘ PSF ∘ exp(−a·ΔVMR)built from a HAPI cross-section LUT. - :mod:
.background— covarianceBconstructors: diagonal, Kronecker (separable AR(1) spatial), low-rank update from a sample stack. - :mod:
.control— control-variable transforms: identity vs whitening (Cholesky / CVT) — the latter dramatically improves Hessian conditioning. - :mod:
.cost— cost+grad+HVP bundles in either model space (δx) or whitened space (ξ). Reverse-mode AD handles the obs-term adjoint. - :mod:
.solve— three solver paths: primal LBFGS, Gauss-Newton with a custom :mod:lineaxlinear solver, and the dual / PSAS reformulation. - :mod:
.diagnostics— reduced χ², degrees-of-freedom-for-signal, posterior covariance proxy via CG on the Hessian.
The math derivation lives in
notebooks/assimilation/00_3dvar_derivation.md and the end-to-end demo in
notebooks/assimilation/06_3dvar_methane_retrieval.ipynb.
Notes¶
assimilation follows the same lazy-import pattern as
:mod:plumax.radtran (PEP 562): submodules are imported only on
first attribute access, so import plumax does not pull in
optimistix / gaussx until you actually touch the assimilation surface.
cost¶
3D-Var cost functions and gradients.
The standard incremental 3D-Var minimises
J(δx) = ½ δxᵀ B⁻¹ δx + ½ ‖y − H(x_b + δx)‖²_R⁻¹
over the increment δx = x − x_b. Two versions live here:
- :func:
build_cost_x— model-space form. The prior term is evaluated via :func:gaussx.solve(B, δx)so any structuredBfrom :mod:.backgroundworks without materialisation. - :func:
build_cost_xi— whitened (CVT) form. The prior term collapses to½ ξᵀ ξand the obs term seesδx = U ξvia the supplied :class:~plumax.assimilation.control.WhiteningTransform. This is the version we use in practice — gradients are well-scaled and the Hessian is identity-plus-low-rank.
In both cases the obs term is differentiated through the JAX forward map
via reverse-mode AD (jax.grad), so we don't need to maintain an explicit
adjoint of the observation operator. The :class:Cost bundle bundles
J, ∇J and a Hessian-vector product Hv so the optimiser /
posterior-covariance machinery downstream can pick whichever it needs.
Cost
dataclass
¶
Cost(value: Callable[[Array], Array], grad: Callable[[Array], Array], value_and_grad: Callable[[Array], tuple[Array, Array]], hvp: Callable[[Array, Array], Array])
Bundle of (value, gradient, Hessian-vector product) for an optimiser.
The three callables share the same input signature — a 1-D state vector — so the optimiser doesn't have to know which control-space we're working in.
build_cost_x
¶
build_cost_x(*, forward_fn: Callable[[Array], Array], background_op: AbstractLinearOperator, obs_inv_variance: float | Array, background_state: Array, observation: Array, state_shape: tuple[int, ...]) -> Cost
Build J(δx) = ½ δxᵀ B⁻¹ δx + ½ (y − H(x_b + δx))ᵀ R⁻¹ (y − H(x_b + δx)).
Parameters¶
forward_fn : Callable
H : 2-D VMR field → obs cube. Must be JAX-traceable.
background_op : lineax operator
B (any structured form from :mod:.background). The cost calls
gaussx.solve(B, δx_flat) once per gradient evaluation.
obs_inv_variance : float or Array
R⁻¹. Pass a scalar for R = σ² I or an array broadcastable
against the obs cube for diagonal heteroscedastic noise.
background_state, observation, state_shape
x_b, y_obs, and the shape of the VMR field (e.g. (ny, nx)).
Source code in src/plumax/assimilation/cost.py
build_cost_xi
¶
build_cost_xi(*, forward_fn: Callable[[Array], Array], whitening: WhiteningTransform | IdentityTransform, obs_inv_variance: float | Array, background_state: Array, observation: Array, state_shape: tuple[int, ...]) -> Cost
Build J(ξ) = ½ ξᵀ ξ + ½ (y − H(x_b + U ξ))ᵀ R⁻¹ (…).
The whitening transform turns the prior into a clean ½‖ξ‖², so the
Hessian is I + (HU)ᵀ R⁻¹ (HU) — a small perturbation of the identity.
Even vanilla LBFGS converges in a handful of iterations regardless of how
badly conditioned B was.
Pass an :class:~plumax.assimilation.control.IdentityTransform
here to recover an unpreconditioned model-space cost with implicit
B = I — useful for unit tests of the obs term in isolation.
Source code in src/plumax/assimilation/cost.py
finite_difference_grad
¶
Central finite differences — used only by tests to validate jax.grad.
O(N) function evaluations, so keep x.size small.
Source code in src/plumax/assimilation/cost.py
control¶
Control-variable transforms for variational data assimilation.
3D-Var minimises a cost J(δx) in some "control space". The two choices
worth keeping straight are:
-
Model space — control variable is the increment
δx = x − x_b. The prior term is½ δxᵀ B⁻¹ δx; oneB⁻¹-solve per gradient evaluation. Hessian conditioning= cond(B⁻¹ + H'ᵀ R⁻¹ H')blows up whenBis poorly scaled. -
Whitened (CVT) space — control variable is
ξwithδx = U ξandB = U Uᵀ. The prior collapses to½ ξᵀ ξand the Hessian becomesI + (HU)ᵀ R⁻¹ (HU)— a small, dense identity-plus-low-rank matrix that even simple LBFGS solves in a handful of iterations regardless of how ill-conditionedBwas. This is the single biggest practical lever you have in 3D-Var.
Both transforms expose the same minimal interface:
apply(xi) → δx(control-space → model-space)apply_inverse(δx) → xi(model-space → control-space; needed once at init if the user gives an x-space prior mean)project_gradient(g_x) → g_xi(chain rule for the prior gradient when you're working with the cost-in-x form; not used in JAX autodiff paths)
We rely on :func:gaussx.cholesky to produce U as a structured operator,
so the whitening cost stays O(B-solve) rather than O(N²) for any of
the structured B flavours in :mod:.background.
IdentityTransform
dataclass
¶
Pass-through transform. Use when the prior is already well-conditioned
or when you want the textbook ½ δxᵀ B⁻¹ δx form for didactic clarity.
WhiteningTransform
dataclass
¶
δx = U ξ where B = U Uᵀ.
Constructed once via :meth:from_background; afterwards each call is a
single structured matvec / triangular solve from gaussx.
Notes¶
Whitening also defines a natural sampler from the prior: δx = U ε
with ε ∼ 𝒩(0, I) is exactly distributed as 𝒩(0, B). We don't
use that here but it's the same operator.
from_background
classmethod
¶
Factor B = U Uᵀ via :func:gaussx.cholesky.
gaussx returns a structured triangular operator (preserving Kronecker / low-rank-update structure), so subsequent matvecs and inverse-solves stay in the cheap regime.
Source code in src/plumax/assimilation/control.py
apply
¶
apply_inverse
¶
δx → U⁻¹ δx via :func:gaussx.solve for structured dispatch.
gaussx.cholesky returns operators that preserve their underlying
structure (a Cholesky of a Kronecker is itself a Kronecker of
Cholesky factors); gaussx.solve knows how to dispatch on those,
whereas lineax.AutoLinearSolver would try to detect generic
triangularity and raise NotImplementedError.
Source code in src/plumax/assimilation/control.py
project_gradient
¶
∂J/∂ξ = Uᵀ ∂J/∂δx — the chain rule for the explicit-cost form.
Not needed when you build the cost in ξ-space and let jax.grad
differentiate end-to-end (which is what :mod:.cost does).
Source code in src/plumax/assimilation/control.py
background¶
Background-error covariance B constructors for 3D-Var.
The prior term ½ δxᵀ B⁻¹ δx in the 3D-Var cost function is dominated, at
the operator level, by how you write down B. We support three flavours
that cover most of what a methane-retrieval workflow ever needs:
============= ====================================================== =================
Flavour Operator structure Cost of B⁻¹
============= ====================================================== =================
diagonal B = diag(σ²) O(N)
Kronecker B = ρ_y ⊗ ρ_x (separable, e.g. AR(1) in each axis) O(N · √N)
low-rank B = λI + U diag(d) Uᵀ (Woodbury via gaussx) O(N · k)
============= ====================================================== =================
All three return a :class:lineax.AbstractLinearOperator (via gaussx) so the
preconditioning transform :class:~plumax.assimilation.control.WhiteningTransform
and the cost gradient can both call :func:gaussx.solve and
:func:gaussx.cholesky uniformly without caring about structure.
The Kronecker constructor uses an exponential (AR(1)-style) correlation
[exp(-|i-j|/L)] along each axis — simple, positive-definite by
construction, and matches the prior families used in operational variational
assimilation systems for spatially-smooth tracer fields.
build_diagonal_background
¶
Build B = diag(σ²) as a :class:lineax.DiagonalLinearOperator.
Parameters¶
variance : float or array
Per-pixel prior variance. Scalar broadcasts to n_pixels.
n_pixels : int, optional
Required when variance is a scalar.
Returns¶
LinearOperator
B as a diagonal operator. gaussx.solve(B, r) is then a
per-element division.
Source code in src/plumax/assimilation/background.py
build_kronecker_background
¶
build_kronecker_background(*, ny: int, nx: int, variance: float, length_scale_y: float, length_scale_x: float) -> LinearOperator
Build B = σ² · (ρ_y ⊗ ρ_x) using :class:gaussx.Kronecker.
The Kronecker structure makes B⁻¹ an O(ny³ + nx³) operation rather
than O((ny·nx)³), which is the difference between feasible and not for
realistic scene sizes (e.g. 256×256 → 65 k state vector vs.
256³ + 256³ ≈ 30 M flops vs. 280 T flops).
The convention here is B = σ² · K_y ⊗ K_x with row-major flattening
δx[i·nx + j] = δx(i, j) — matches vmr_field.reshape(-1) order.
Source code in src/plumax/assimilation/background.py
build_lowrank_background
¶
build_lowrank_background(*, samples: ndarray, rank: int, regularization: float = 1e-06, random_state: int = 0) -> LinearOperator
Estimate B from a stack of state samples and wrap as λI + U D Uᵀ.
Parameters¶
samples : np.ndarray
Sample matrix, shape (n_samples, n_state) — e.g. flattened VMR
snapshots from a NWP ensemble or a climatology.
rank : int
Number of leading SVD components to keep. Costs O(n_state · k)
per solve.
regularization : float
Diagonal floor λ so the resulting operator is strictly PD even
when samples is rank-deficient. Must be > 0.
random_state : int
Seed for :func:gaussx.randomized_svd, used when
rank < 0.5 * min(n_samples, n_state) (otherwise a full SVD runs).
Returns¶
LinearOperator
gaussx.LowRankUpdate tagged symmetric + PSD; gaussx.solve(B, r)
uses Woodbury internally.
Source code in src/plumax/assimilation/background.py
obs_operator¶
JAX observation operator for the methane VMR retrieval.
The full forward map factors as
x ──┐
│ ΔVMR = x − x_ref
▼
Δτ(ν, i, j) = a(ν) · ΔVMR(i, j) a(ν) = σ(ν, T, P) · N · ΔX · AMF
│
▼
L_hr(i, j, ν) = exp(−Δτ) (or 1 − Δτ for linear)
│
▼ PSF (per channel)
▼ GSD (per channel)
▼ SRF (band integration)
y_obs(I, J, b)
Everything below the LUT lookup is JAX, so
forwardruns end-to-end under :func:jax.jit,jax.grad(loss∘forward)produces the discrete adjoint for free,jax.jvp(forward, …)produces the tangent-linear,
without us writing or maintaining any explicit adjoint. The reason
radtran/instrument.py does expose hand-rolled adjoint methods is to
let unit tests verify the inner-product identity independently — JAX's autodiff
inherits the correctness, not the other way around.
The factory :meth:RadianceObservationModel.from_lut precomputes a(ν) once
from the HAPI cross-section LUT so the per-iteration cost in 3D-Var is dominated
by the cube-shaped operations (PSF, GSD, SRF).
RadianceObservationModel
dataclass
¶
RadianceObservationModel(absorption_coeff_hr: Array, srf_matrix: Array, vmr_reference: float = 0.0, psf: PointSpreadFunction | None = None, gsd: GroundSamplingDistance | None = None)
Bundle of precomputed pieces that defines the forward operator H.
Attributes¶
absorption_coeff_hr : jnp.ndarray
Per-wavenumber absorption coefficient a(ν) = σ(ν,T,P) · N · ΔX · AMF,
shape (n_lambda,). Captures everything except the VMR field, so the
forward map is just L_hr(i,j,ν) = exp(-a(ν) · ΔVMR(i,j)).
srf_matrix : jnp.ndarray
L1-normalised SRF matrix, shape (n_bands, n_lambda).
vmr_reference : float
Background VMR x_ref around which radiances are normalised. Setting
vmr_reference = 0 recovers absolute Beer-Lambert; setting it to the
scene's true background produces the differential signal that matched
filter retrievals consume.
psf, gsd : optional
JAX-side instrument operators from :mod:plumax.radtran.instrument.
None skips that stage — convenient for hyperspectral tests where
the spectral cube is already at sensor resolution.
from_lut
classmethod
¶
from_lut(lut: Dataset, *, srf: SpectralResponseFunction, T_K: float, p_atm: float, path_length_cm: float, amf: float, vmr_reference: float = 0.0, psf: PointSpreadFunction | None = None, gsd: GroundSamplingDistance | None = None, var: str = 'absorption_cross_section') -> RadianceObservationModel
Build the operator from a HAPI σ(ν,T,P) LUT and an SRF.
Pre-collapses σ · N · ΔX · AMF into a single a(ν) vector so the
forward call only multiplies by ΔVMR per pixel.
The HR spectral grid is taken from the SRF (srf.wavelengths_hr_nm)
and converted to wavenumbers for the σ lookup. This is the grid the SRF
matrix's columns are indexed by, so the subsequent
einsum("bl, ijl -> ijb") band integration contracts aligned spectral
bins by construction. Earlier revisions accepted a separate nu_obs
argument; when callers passed a wavenumber-ordered grid while the SRF
was wavelength-ordered (common whenever wl = 1e7/ν was sorted),
the einsum silently mixed non-corresponding bins — self-consistent for
twin retrievals but physically wrong for anything cross-validated.
Source code in src/plumax/assimilation/obs_operator.py
forward
¶
Map a 2-D VMR field to a band-integrated obs cube.
Parameters¶
vmr_field : Array
Per-pixel VMR, shape (ny_hr, nx_hr).
linear : bool
If True, replace exp(-Δτ) by its Maclaurin-1 approximation
1 - Δτ. The tangent-linear of the nonlinear forward at
vmr_field == vmr_reference coincides with this; the matched
filter is exactly this case with δx propagated through.
Returns¶
Array
Sensor-space radiance cube, shape (ny_lr, nx_lr, n_bands)
(ny_lr = ny_hr // f, nx_lr = nx_hr // f if a GSD is set).
Source code in src/plumax/assimilation/obs_operator.py
make_forward
¶
Return a closure that captures the model so it can be jit-compiled.
jax.jit(model.forward) works in JAX ≥ 0.4 because frozen dataclasses
without register_pytree_node_class are hashable and become static
arguments — but explicit closures are easier to compose with
jax.value_and_grad and friends, and they work uniformly across
JAX versions.
Source code in src/plumax/assimilation/obs_operator.py
solve¶
Optimisers for 3D-Var: primal LBFGS, Gauss-Newton, and a dual PSAS path.
Three entry points, each tuned to a different conditioning story:
- :func:
run_lbfgs— black-box minimiser. Pass any cost+grad bundle from :mod:.cost. Robust, but iteration count trackscond(Hessian), so use this with a :class:~plumax.assimilation.control.WhiteningTransform. - :func:
run_gauss_newton— assumes the cost is a sum of squared residuals (which 3D-Var is, by construction). Each outer step solves a linear system(JᵀJ) δ = -Jᵀrvia a user-chosen :mod:lineaxsolver (CG by default, so structuredBkeeps Woodbury / Kronecker on the hot path). - :func:
run_dual_psas— the observation-space reformulation. Whendim(y) ≪ dim(x)(typical for compact plumes), solving(H'BH'ᵀ + R) λ = dand recoveringδx = B H'ᵀ λis dramatically cheaper than the primal — and exactly equivalent for the linear forward.
The dual path is also the "matched filter, generalised": the matched-filter
target spectrum t falls out as H' applied to a unit increment, and
the PSAS solution δx̂ = B H'ᵀ (H'BH'ᵀ + R)⁻¹ d is the BLUE estimator that
the pixel-wise matched filter approximates with B = ∞ I, R = Σ_pixel.
SolveResult
dataclass
¶
Common result type for all three solvers.
Attributes¶
state : np.ndarray
Optimal control variable (ξ for whitened, δx for model-space,
δx for the dual after the back-transform).
n_steps : int
Number of iterations consumed (-1 if the solver doesn't report).
cost_history : np.ndarray
Cost trajectory; for solvers that don't expose it, this is empty.
run_lbfgs
¶
run_lbfgs(cost: Cost, initial_state: Array, *, rtol: float = 1e-06, atol: float = 1e-09, max_steps: int = 200) -> SolveResult
Minimise cost.value via :class:optimistix.LBFGS.
The cost is wrapped to match optimistix's (y, args) → scalar signature.
The implicit-function adjoint is the default — fine for this use case
because we don't differentiate through the solve.
Source code in src/plumax/assimilation/solve.py
run_gauss_newton
¶
run_gauss_newton(*, residual_fn: Callable[[Array], Array], initial_state: Array, linear_solver: AbstractLinearSolver | None = None, rtol: float = 1e-06, atol: float = 1e-09, max_steps: int = 50) -> SolveResult
Run :class:optimistix.GaussNewton for min ½ ‖r(y)‖².
residual_fn should return a flat residual vector
[B^{-½}(δx); R^{-½}(y - H(x_b + δx))] (the "augmented" 3D-Var residual);
optimistix internally constructs the Jacobian and solves the normal
equations (JᵀJ) δ = -Jᵀr using linear_solver. Pass
lineax.CG(...) for a matrix-free inner solve (useful when the
Jacobian has Kronecker / low-rank structure that full Cholesky would
materialise), or lineax.Cholesky() for small dense systems.
Source code in src/plumax/assimilation/solve.py
run_dual_psas
¶
run_dual_psas(*, forward_fn: Callable[[Array], Array], background_op: AbstractLinearOperator, obs_inv_variance: float, background_state: Array, observation: Array, state_shape: tuple[int, ...], cg_rtol: float = 1e-06, cg_atol: float = 1e-09, cg_max_steps: int = 200) -> SolveResult
Solve the dual 3D-Var system in observation space.
For a forward operator that is linear in the state perturbation the
optimum of J(δx) = ½ δxᵀ B⁻¹ δx + ½(y - H(x_b + δx))ᵀ R⁻¹(y - H(x_b + δx))
satisfies
(H' B H'ᵀ + R) λ = d, with d = y − H(x_b)
δx̂ = B H'ᵀ λ.
When dim(y) ≪ dim(x) this is much cheaper than primal — and it's
exactly the estimator that the pixel-wise matched filter approximates.
Parameters¶
forward_fn : Callable
Full-state forward x → y (i.e. the same object returned by
:meth:RadianceObservationModel.make_forward). For the dual to be
correct, H must be linear in δx = x − x_b. We extract the
tangent-linear action by differencing at x_b once (see
base_obs below) and the adjoint via :func:jax.vjp. Callers
typically pass model.make_forward(linear=True).
background_op : lineax operator
B.
obs_inv_variance : float
Scalar R⁻¹ (heteroscedastic R can be folded into forward_fn
at the cost of clarity — keep it scalar for the demo).
background_state, observation, state_shape
Self-explanatory.
cg_rtol, cg_atol, cg_max_steps
Tolerances for the CG solve of the dual system.
Source code in src/plumax/assimilation/solve.py
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | |
diagnostics¶
Posterior diagnostics for a converged 3D-Var solution.
After the optimiser has produced x̂, three quick scalars tell you whether
the result is statistically consistent with the prior + likelihood you
specified:
- :func:
reduced_chi_squared—χ² = ‖y − H(x̂)‖²_R⁻¹ / dim(y). ≈ 1 means the posterior fits the observations to the noise level. Much smaller → over-fitting (R too generous); much larger → systematic errors not in R. - :func:
degrees_of_freedom_for_signal—DFS = trace(I − A)with the averaging kernelA = Bₐ B⁻¹(Rodgers, 2000). For low-rankBthis is cheap; for the dense form we use Hutchinson's stochastic trace estimator. - :func:
posterior_covariance_proxy— the inverse Hessian at the optimum. Returned as a callable that takes a probe vectorvand returnsBₐ v(i.e. you can evaluate variances per-pixel without forming the fullBₐmatrix).
These are diagnostics, not exact statistics — for the linear-Gaussian case they coincide with the textbook posterior; for the nonlinear case they're Laplace approximations around the optimum.
reduced_chi_squared
¶
reduced_chi_squared(*, forward_fn: Callable[[Array], Array], estimated_state: Array, observation: Array, obs_inv_variance: float | Array) -> float
χ²_red = (y - H(x̂))ᵀ R⁻¹ (y - H(x̂)) / dim(y).
Values near 1 indicate the posterior is consistent with the observation error model; ≪ 1 → noise overestimated; ≫ 1 → noise underestimated or systematic forward-model error.
Source code in src/plumax/assimilation/diagnostics.py
degrees_of_freedom_for_signal
¶
degrees_of_freedom_for_signal(*, hessian_vector_product: Callable[[Array], Array], state_size: int, background_op: AbstractLinearOperator | None = None, whitened: bool = False, n_probes: int = 32, seed: int = 0, cg_rtol: float = 1e-06, cg_atol: float = 1e-10, cg_max_steps: int = 2000) -> float
Estimate DFS = trace(I − Bₐ B⁻¹) (Rodgers 2000, §2.5).
For the linear-Gaussian case the averaging kernel is A = I − Bₐ B⁻¹
and DFS = trace(A) measures how many independent pieces of information
the observations contributed (DFS = 0 → no information; DFS = N →
observations fully pin the state). The posterior covariance is the
inverse Hessian at the optimum, Bₐ ≈ Hess⁻¹. Because DFS is invariant
under the control-variable change δx = U ξ, the estimate must be
computed in the space the supplied hessian_vector_product lives in —
mixing an ξ-space Hessian with a model-space B gives a wrong answer for
any B ≠ I.
Two modes, one per cost builder:
- Model space (
whitened=False, the default) — pass the Hessian from :func:plumax.assimilation.cost.build_cost_xandbackground_op = B. Each Hutchinson probe needs oneB⁻¹ zvia :func:gaussx.solve(structured dispatch) and oneHess⁻¹ wvia CG:DFS ≈ E[zᵀz − zᵀ Hess⁻¹ B⁻¹ z]. - Whitened space (
whitened=True) — pass the Hessian from :func:plumax.assimilation.cost.build_cost_xi(Hess_ξ = I + (HU)ᵀR⁻¹HU). The whitened prior is the identity, soB⁻¹drops out:DFS ≈ E[zᵀz − zᵀ Hess_ξ⁻¹ z]andbackground_opmust be omitted.
Cost per probe: O(n_CG · forward) — dominated by the CG solve.
Earlier versions computed trace(B · Hess), which is not DFS: in the
zero-information limit (Hess = B⁻¹) it returns N instead of the
correct 0. See PR-review thread on diagnostics.py.
Source code in src/plumax/assimilation/diagnostics.py
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | |
posterior_covariance_proxy
¶
posterior_covariance_proxy(*, hessian_vector_product: Callable[[Array], Array], state_size: int, whitening: WhiteningTransform | None = None, cg_rtol: float = 1e-06, cg_atol: float = 1e-09, cg_max_steps: int = 200) -> Callable[[jax.Array], jax.Array]
Return a callable v → Bₐ v (model-space posterior covariance) via CG.
Bₐ ≈ Hess⁻¹ is the Laplace-approximation posterior covariance. We
expose it as a matvec (rather than materialising) so per-pixel variance
estimates remain O(state · cg_iters) — fine for moderate scenes.
Pass the Hessian that matches whitening:
whitening=None(default) — a model-space Hessian (from :func:plumax.assimilation.cost.build_cost_x); the CG solve returnsHess⁻¹ v = Bₐ vdirectly.- a :class:
~plumax.assimilation.control.WhiteningTransform— a whitened Hessian (from :func:plumax.assimilation.cost.build_cost_xi), whose inverse is the ξ-space covariance, notBₐ. The model-space covariance is recovered asBₐ = U Hess_ξ⁻¹ Uᵀ, so each apply un-whitens on both sides:v → U (Hess_ξ⁻¹ (Uᵀ v)).