Matched filter¶
The one matched-filter core: background estimators (empirical, shrunk, low-rank, cluster, streaming), the kernel on gaussx operators, targets from the observation model, and xarray I/O. Theory: matched filter.
matched_filter
¶
Matched filter for hyperspectral methane retrieval.
The matched filter (MF) is the maximum-SNR linear detector for a known target
signature t(ν) in additive Gaussian noise with covariance Σ. It is
also the one-CG-iteration limit of the dual / PSAS 3D-Var solver with a
flat (identity) prior — see notebooks/matched_filter/00_mf_derivation.md.
Score formula (per pixel)::
y(x) = (x − μ)ᵀ Σ⁻¹ t / (tᵀ Σ⁻¹ t)
where x is the observed spectrum, μ is the background mean, and t
is the target signature — typically the radiance Jacobian ∂L/∂VMR from
the Beer–Lambert forward model at the background state.
Architecture¶
- Estimation (
background,cluster,streaming) — statistical estimation uses scikit-learn (EmpiricalCovariance,LedoitWolf,TruncatedSVD,GaussianMixture). Not differentiable — that's fine, MF doesn't need it. - Application (
core) — the per-pixel score evaluation runs in JAX and dispatches on gaussx operator structure. Covariance estimators return a :class:lineax.AbstractLinearOperator(dense, low-rank + Tikhonov via :class:gaussx.LowRankUpdate, etc.) so the kernel is single-implementation. - Target signatures (
target) — derived from an existing :class:radtrax.retrieval.var3d.RadianceObservationModelvia :func:jax.jvp(directional derivatives) or a finite-amplitude secant, or per pixel from the layered :class:radtrax.ForwardModel(unit_absorption_target, radtrax#37). No duplicate Beer–Lambert code. - I/O (
io) — xarray wrappers for multi-scene workflows.
The derivation notebook is ported with the docs (#24).
Ported from plumax 0.1.2 at 28f257e (plumax.matched_filter); only import
paths changed.
The package resolves its public names lazily (PEP 562), so they are documented here from the submodules that define them.
core¶
Matched-filter kernel — one implementation for any covariance structure.
The classical matched-filter score is
.. math:: y(x) = \frac{(x - \mu)^\top \Sigma^{-1} t}{t^\top \Sigma^{-1} t}
with the special case :math:y(\mu + \alpha t) = \alpha (so y is an
unbiased linear estimator of the plume enhancement amplitude). The per-pixel
evaluation cost is dominated by :math:w = \Sigma^{-1} t, a single solve
that can be precomputed once per scene; after that each pixel is a dot
product of length n_bands.
This module wraps that into a single kernel that works for any
:class:lineax.AbstractLinearOperator covariance — dense, diagonal, low-rank
+ Tikhonov (Woodbury via :class:gaussx.LowRankUpdate), or Kronecker. The
solve is dispatched via :func:gaussx.solve, so the MF caller never sees the
structure.
Ported from plumax 0.1.2 at 28f257e (src/plumax/matched_filter/core.py); only import paths changed.
validate_mf_inputs
¶
Public fail-fast check for (Σ, t) compatibility.
Runs the same tᵀ Σ⁻¹ t > 0 test that :func:apply_pixel and
:func:apply_image perform automatically, but without the rest of the
MF work — useful for catching user errors before jitting a pipeline
or batching over many pixels.
Raises¶
ValueError
If target is all zero, or if the target energy tᵀ Σ⁻¹ t is
not strictly positive (singular or indefinite covariance along the
target direction).
Source code in src/radtrax/retrieval/matched_filter/core.py
apply_pixel
¶
apply_pixel(pixel: Float[ArrayLike, ' B'], mean: Float[ArrayLike, ' B'], cov_op: LinearOperator, target: Float[ArrayLike, ' B']) -> Float[Array, '']
Matched-filter score for a single pixel spectrum.
Parameters¶
pixel
Observed radiance spectrum at one pixel, shape (n_bands,).
mean
Background mean spectrum, shape (n_bands,).
cov_op
Background covariance as a :class:lineax.AbstractLinearOperator. Any
structure is fine — :func:gaussx.solve dispatches on operator type
(Woodbury for :class:gaussx.LowRankUpdate, Cholesky for dense, etc.).
target
Target signature t(ν), shape (n_bands,) — typically the
radiance Jacobian ∂L/∂VMR at the background state.
Returns¶
Float[Array, ""] Scalar score — an unbiased linear estimator of the plume amplitude under the additive-Gaussian-noise model.
Source code in src/radtrax/retrieval/matched_filter/core.py
apply_image
¶
apply_image(cube: Float[ArrayLike, 'H W B'], mean: Float[ArrayLike, ' B'], cov_op: LinearOperator, target: Float[ArrayLike, ' B']) -> Float[Array, 'H W']
Matched-filter score map for a hyperspectral cube.
Precomputes w = Σ⁻¹ t once, then evaluates
(cube − μ) · w / (tᵀ w) in a single einsum — no per-pixel Python loop.
Parameters¶
cube
Radiance cube with shape (H, W, n_bands).
mean, cov_op, target
See :func:apply_pixel.
Returns¶
Float[Array, "H W"]
Score image. Pixel (i, j) is the estimated plume amplitude at
that location.
Source code in src/radtrax/retrieval/matched_filter/core.py
matched_filter_snr
¶
matched_filter_snr(amplitude: float, cov_op: LinearOperator, target: Float[ArrayLike, ' B']) -> Float[Array, '']
Theoretical SNR of the matched filter at a given plume amplitude.
For x = μ + α t + ε with ε ∼ N(0, Σ), the MF score has
mean = α and variance = 1 / (tᵀ Σ⁻¹ t). So the detection SNR is
.. math:: \text{SNR} = \alpha \sqrt{t^\top \Sigma^{-1} t}.
Parameters¶
amplitude
The plume amplitude α at which to evaluate SNR.
cov_op, target
See :func:apply_pixel.
Returns¶
Float[Array, ""] Scalar SNR (dimensionless).
Source code in src/radtrax/retrieval/matched_filter/core.py
detection_threshold
¶
detection_threshold(false_alarm_rate: float, cov_op: LinearOperator, target: Float[ArrayLike, ' B']) -> Float[Array, '']
Gaussian detection threshold on the MF score for a given FAR.
Under the null (no plume), the score is :math:N(0, 1/(t^\top \Sigma^{-1} t)).
The threshold above which the false-alarm rate does not exceed FAR is
.. math:: \tau = \Phi^{-1}(1 - \text{FAR}) / \sqrt{t^\top \Sigma^{-1} t}.
Parameters¶
false_alarm_rate
Target FAR, in (0, 1) — e.g. 1e-6 for one false alarm per
million pixels under Gaussian assumptions.
cov_op, target
See :func:apply_pixel.
Returns¶
Float[Array, ""]
Threshold in score units (same units as amplitude in
:func:matched_filter_snr).
Source code in src/radtrax/retrieval/matched_filter/core.py
background¶
Background mean and covariance estimators for the matched filter.
Estimation is not on the gradient path — the matched filter treats the
background :math:(\mu, \Sigma) as known inputs. That lets us lean on
scikit-learn's mature estimators (which have careful numerics, shrinkage,
robust variants, etc.) without any JAX/autodiff constraints. The outputs
are wrapped as :mod:lineax / :mod:gaussx linear operators so that
:func:radtrax.retrieval.matched_filter.core.apply_image can use the same
gaussx.solve dispatch path for all covariance flavours:
============================== ====================================================
Estimator Operator returned
============================== ====================================================
estimate_cov_empirical :class:lineax.MatrixLinearOperator (dense)
estimate_cov_shrunk :class:lineax.MatrixLinearOperator (dense, shrunk)
estimate_cov_lowrank :class:gaussx.LowRankUpdate (λI + UDUᵀ)
============================== ====================================================
The low-rank path uses scikit-learn's randomised :class:TruncatedSVD, which
is O(n · k) on a (n_samples, n_bands) matrix and is typically 10–100×
faster than a full SVD for the small k a matched filter needs.
Ported from plumax 0.1.2 at 28f257e (src/plumax/matched_filter/background.py); only import paths changed.
estimate_mean
¶
estimate_mean(cube: Float[ArrayLike, 'H W B'], *, method: _RobustMethod = 'mean', trim_proportion: float = 0.1, huber_c: float = 1.345) -> Float[ndarray, ' B']
Estimate the per-band background mean μ from a scene.
Parameters¶
cube
Hyperspectral scene of shape (H, W, n_bands).
method
- 'mean' — arithmetic mean (sensitive to anomalies).
- 'median' — per-band median (very robust).
- 'trimmed' — two-sided trimmed mean (scipy.stats.trim_mean).
- 'huber' — Huber M-estimator (sklearn's HuberRegressor on
a constant design).
trim_proportion
Fraction discarded from each tail for the trimmed mean. Ignored
otherwise.
huber_c
Huber influence threshold in units of the per-band MAD. Ignored
unless method='huber'.
Pixels with a non-finite band (NaN fill, saturated or missing data) are left out, as in every estimator of this module.
Returns¶
np.ndarray
Mean spectrum of length n_bands.
Source code in src/radtrax/retrieval/matched_filter/background.py
estimate_cov_empirical
¶
estimate_cov_empirical(cube: Float[ArrayLike, 'H W B'], mean: Float[ArrayLike, ' B'] | None = None, *, ridge: float = 0.0, mask: Bool[ArrayLike, '*pixels'] | None = None) -> LinearOperator
Empirical covariance via :class:sklearn.covariance.EmpiricalCovariance.
Returns a dense :class:lineax.MatrixLinearOperator. Optionally adds a
diagonal ridge for numerical PD-ness when n_samples ≲ n_bands.
mask (boolean exclude-pixels) drops flagged pixels — e.g. a detected
plume — so Σ is estimated from background only, consistent with a robust
masked μ.
Source code in src/radtrax/retrieval/matched_filter/background.py
estimate_cov_shrunk
¶
estimate_cov_shrunk(cube: Float[ArrayLike, 'H W B'], mean: Float[ArrayLike, ' B'] | None = None, *, method: Literal['ledoit_wolf', 'oas'] = 'ledoit_wolf', mask: Bool[ArrayLike, '*pixels'] | None = None) -> LinearOperator
Shrinkage covariance estimator.
Uses Ledoit–Wolf (method='ledoit_wolf', default) or OAS
('oas') — both shrink the sample covariance toward a scaled identity
and are PD by construction even when n_samples < n_bands. mask
(boolean exclude-pixels) drops flagged pixels before fitting.
Source code in src/radtrax/retrieval/matched_filter/background.py
shrink_covariance
¶
shrink_covariance(empirical: Float[ArrayLike, 'B B'], *, method: Literal['ledoit_wolf', 'oas'], n_samples: int, fourth_moment: float | None = None) -> Float[ndarray, 'B B']
Shrink a 1/n empirical covariance toward a scaled identity.
Returns (1 − s)·Sₙ + s·μ·I with μ = tr(Sₙ)/p, where Sₙ is the
maximum-likelihood (1/n) scatter of n samples in p bands — the
normalisation every estimator in this module uses — and the intensity
s ∈ [0, 1] is estimated from the data. Both methods reproduce
scikit-learn's :class:~sklearn.covariance.LedoitWolf and
:class:~sklearn.covariance.OAS exactly. This is the form a streaming
pass needs (:func:~radtrax.retrieval.matched_filter.streaming.streaming_background),
where only the moments, not the samples, are kept.
-
"ledoit_wolf"(Ledoit & Wolf 2004), withdₖ = xₖ − x̄andm₄ = (1/n) Σₖ ‖dₖ‖⁴::δ² = ‖Sₙ − μ I‖²_F β̄² = (1/n²) Σₖ ‖dₖdₖᵀ − Sₙ‖²_F = (m₄ − ‖Sₙ‖²_F) / n s = min(β̄², δ²) / δ² (0 when δ² = 0)
β̄² needs the fourth moment m₄, which Sₙ alone does not carry.
-
"oas"(Chen et al. 2010, as scikit-learn implements it, without the2/pterms of their Eq. 23)::s = min(1, (tr(Sₙ²) + tr(Sₙ)²) / ((n + 1)·(tr(Sₙ²) − tr(Sₙ)²/p)))
Parameters¶
empirical
1/n empirical covariance Σₖ dₖdₖᵀ / n, shape (B, B).
method
Shrinkage-intensity estimator.
n_samples
Number of samples behind empirical.
fourth_moment
m₄ over the same centred samples; required for
"ledoit_wolf", ignored by "oas".
:meth:~radtrax.retrieval.matched_filter.streaming.WelfordAccumulator.fourth_moment
tracks it.
Examples¶
import numpy as np x = np.random.default_rng(0).normal(size=(30, 4)) d = x - x.mean(axis=0) s_n = d.T @ d / 30 m4 = float(np.mean(np.sum(d * d, axis=1) ** 2)) shrink_covariance( ... s_n, method="ledoit_wolf", n_samples=30, fourth_moment=m4 ... ).shape (4, 4)
Source code in src/radtrax/retrieval/matched_filter/background.py
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | |
estimate_cov_lowrank
¶
estimate_cov_lowrank(cube: Float[ArrayLike, 'H W B'], mean: Float[ArrayLike, ' B'] | None = None, *, rank: int, tikhonov: float, random_state: int | PRNGKeyArray | None = 0, n_oversamples: int = 10, mask: Bool[ArrayLike, '*pixels'] | None = None, rank_rtol: float = 1e-10) -> LinearOperator
Low-rank + Tikhonov covariance: Σ = λI + V D Vᵀ.
Uses :func:gaussx.randomized_svd (Halko–Martinsson–Tropp, JAX, 5 power
iterations) to find the top rank spectral directions of
the sample covariance. Returned as a :class:gaussx.LowRankUpdate so that
:func:gaussx.solve routes through the Woodbury identity — the MF
precompute cost is O(n_bands · rank + rank³) instead of
O(n_bands³).
Parameters¶
cube
Hyperspectral scene (H, W, n_bands).
mean
Background mean. If None, subtracts the sample mean.
rank
Number of leading components to keep. Clamped to
min(rank, n_samples - 1, n_bands - 1).
tikhonov
Diagonal floor λ > 0 — ensures strict PD.
random_state
Seed for the randomised SVD: an int (converted with
jax.random.key), a JAX PRNG key, or None (seed 0).
n_oversamples
Extra random directions for the Halko sampling — higher is slower but
better-conditioned.
mask
Boolean exclude-pixels mask; flagged pixels (e.g. a detected plume) are
dropped before the SVD so Σ reflects background only.
rank_rtol
Components whose covariance eigenvalue d_i is below
rank_rtol · max(d) are dropped before building the operator. On a
rank-deficient scene (constant regions, duplicated spectra) those
d_i ≈ 0 would make gaussx's Woodbury capacitance diag(1/d) +
VᵀL⁻¹U blow up to inf/NaN; dropping them keeps every score finite.
Source code in src/radtrax/retrieval/matched_filter/background.py
cluster¶
Cluster-based and spatially-adaptive background estimators.
When a scene is heterogeneous (land/water, bright/dark surfaces, different
land-cover classes) a single global (μ, Σ) smears together statistically
distinct populations and the MF loses power. Two standard fixes:
- GMM clustering — fit a Gaussian mixture on the per-pixel spectra,
label each pixel, and run the MF with the local cluster's
(μ_k, Σ_k). :func:gmm_cluster_backgroundreturns a :class:ClusterBackground: the labels and one(mean, cov_operator)pair per cluster. - Adaptive spatial windows — for each pixel, estimate
(μ_{ij}, \sigma^2_{ij})from a local neighbourhood. Only supports a diagonal local covariance (keeps memory atO(H·W·n_bands)) — for full local covariance, prefer the GMM route.
Both are estimated with scikit-learn / scipy, off the JAX gradient path.
The fitted :class:ClusterBackground is an Equinox module, so the filter can
still be differentiated with respect to its means and covariances.
Ported from plumax 0.1.2 at 28f257e (src/plumax/matched_filter/cluster.py); its frozen
dataclass became an Equinox module in radtrax#59.
ClusterBackground
¶
Bases: Module
Per-cluster background model.
An Equinox module (radtrax#59): the labels, means and covariance operators
are leaves. It passes through eqx.filter_jit with the image, and
through eqx.filter_vmap over a batch of images with
in_axes=(None, 0) (the default would map the background's own arrays).
Two backgrounds share one compilation when they have the same number of
clusters, operator types and dtypes; the number of clusters depends on
the data, not on n_clusters.
Under-populated clusters reuse the global (μ, Σ), so one array or
operator can appear at several positions. Each position is still its own
leaf: eqx.tree_at and gradients act per position, so the gradient
with respect to the shared global mean is the sum over those positions.
Attributes¶
labels
Cluster assignment per pixel, shape (H, W) with values in
[0, n_clusters); -1 marks a pixel with a non-finite band,
left out of the fit (:func:apply_cluster_mf scores it NaN).
means
Tuple of per-cluster mean spectra.
cov_operators
Tuple of per-cluster covariance operators (same structure across
clusters — dense from :func:estimate_cov_empirical or
:func:estimate_cov_shrunk).
gmm_cluster_background
¶
gmm_cluster_background(cube: Float[ArrayLike, 'H W B'], *, n_clusters: int, cov_estimator: str = 'ledoit_wolf', random_state: int | None = 0, bayesian: bool = False) -> ClusterBackground
Fit a Gaussian mixture and build per-cluster background models.
Uses :class:sklearn.mixture.GaussianMixture (or
:class:BayesianGaussianMixture when bayesian=True) on the per-pixel
spectra. Each cluster gets its own shrunk covariance estimate so that
clusters with few pixels remain well-conditioned.
Parameters¶
cube
Hyperspectral cube, shape (H, W, n_bands).
n_clusters
Number of mixture components (upper bound for the Bayesian variant).
cov_estimator
'ledoit_wolf' (default), 'oas', or 'empirical'.
random_state
Passed through to sklearn for reproducibility.
bayesian
If True, use Dirichlet-process-style :class:BayesianGaussianMixture,
which prunes unused components automatically.
Returns¶
ClusterBackground
The mixture is fitted on the pixels whose bands are all finite; the
rest are labelled -1.
Source code in src/radtrax/retrieval/matched_filter/cluster.py
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 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 | |
adaptive_window_background
¶
adaptive_window_background(cube: Float[ArrayLike, 'H W B'], *, window_size: int, guard_size: int = 0) -> tuple[Float[ndarray, 'H W B'], Float[ndarray, 'H W B']]
Per-pixel local mean and per-band variance over a square window.
For each pixel (i, j), estimate μ_{ij} and a diagonal local
covariance diag(σ²_{ij,b}) using a window_size × window_size
uniform window centred on the pixel (reflecting at boundaries).
Full local covariances would be (H · W · n_bands²) — too big for
realistic scenes. For a dense local covariance, cluster with GMM instead.
Parameters¶
cube
Hyperspectral cube, shape (H, W, n_bands).
window_size
Edge length of the averaging window. Must be odd and >= 3.
guard_size
Edge length of a central guard region excluded from each pixel's
window (a guard annulus). Must be odd and 0 <= guard_size <
window_size. The default 0 includes the centre pixel — but then a
plume pixel contaminates its own local background; guard_size=1
excludes just the centre pixel, larger values exclude a bigger core.
Returns¶
local_mean
Per-pixel mean spectrum, shape (H, W, n_bands).
local_variance
Per-pixel per-band (population) variance, shape (H, W, n_bands).
Window statistics use valid pixels only (a pixel with any non-finite
band carries no weight); invalid pixels, and pixels whose window
holds fewer than two valid pixels (variance) or none (mean), are
NaN.
Source code in src/radtrax/retrieval/matched_filter/cluster.py
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | |
apply_cluster_mf
¶
apply_cluster_mf(cube: Float[ArrayLike, 'H W B'], cluster: ClusterBackground, target: Float[ArrayLike, ' B']) -> Float[Array, 'H W']
Matched-filter scores with each pixel's own cluster background.
Pixel (i, j) with label k scores
(x − μ_k)ᵀ Σ_k⁻¹ t / (tᵀ Σ_k⁻¹ t), the
:func:~radtrax.retrieval.matched_filter.core.apply_image score with
the cluster's (μ_k, Σ_k). One solve per cluster; the dispatch is a
jnp.where per cluster, so the function runs under
eqx.filter_jit and is differentiable in the cube, the target and the
background.
Parameters¶
cube
Radiance cube (H, W, n_bands), the same spatial shape as
cluster.labels.
cluster
Background from :func:gmm_cluster_background.
target
Target signature (n_bands,).
Returns¶
Float[Array, "H W"]
Score map; NaN where the label is outside [0, n_clusters)
(the -1 of a pixel with a non-finite band).
Raises¶
ValueError
If the label map does not match the cube, or (outside jit) a
cluster's tᵀ Σ_k⁻¹ t is not positive.
Source code in src/radtrax/retrieval/matched_filter/cluster.py
apply_adaptive_mf
¶
apply_adaptive_mf(cube: Float[ArrayLike, 'H W B'], mean: Float[ArrayLike, 'H W B'], variance: Float[ArrayLike, 'H W B'], target: Float[ArrayLike, ' B'], *, ridge: float | Float[Array, ''] = 0.0) -> Float[Array, 'H W']
Matched filter against a local diagonal-covariance background.
Scores every pixel against its own window statistics from
:func:adaptive_window_background, μ(r) and
Λ(r) = diag(σ²(r) + ridge), with the amplitude normalisation of
:func:~radtrax.retrieval.matched_filter.core.apply_image::
α(r) = tᵀ Λ(r)⁻¹ (x(r) − μ(r)) / (tᵀ Λ(r)⁻¹ t)
= Σ_b t_b (x_b − μ_b) / σ²_b / Σ_b t_b² / σ²_b
the per-pixel maximum-likelihood amplitude under an independent-band
Gaussian background. Runs in JAX and is differentiable; a band with
σ² + ridge ≤ 0 makes its pixel unscoreable without putting a NaN in
the gradient of the others (double-where).
Parameters¶
cube
Radiance cube (H, W, n_bands).
mean, variance
Local statistics of the same shape, from
:func:adaptive_window_background.
target
Target signature (n_bands,); must not be all zero.
ridge
Non-negative variance floor added to every σ²; use it when flat
windows give zero variance.
Returns¶
Float[Array, "H W"]
Score map; NaN where the pixel, its local mean or its local
variance is non-finite, or a band's σ² + ridge is not positive.
Raises¶
ValueError
If the shapes disagree, ridge is negative, or (outside jit)
the target is all zero.
Source code in src/radtrax/retrieval/matched_filter/cluster.py
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 | |
streaming¶
Streaming background estimation via Welford's algorithm.
For multi-scene MF workflows (e.g. aggregating background statistics across a
campaign of flights), the full (n_samples, n_bands) stack does not fit in
memory. The parallel Welford update [#welford1962] [#chan1979] maintains a
mean and (scaled) covariance in one pass, merges partial results from
separate chunks exactly, and is numerically stable:
.. math:: n \leftarrow n_A + n_B \delta = \mu_B - \mu_A \mu \leftarrow \mu_A + \delta \cdot n_B / n M_2 \leftarrow M_{2,A} + M_{2,B} + \delta \delta^\top \cdot n_A n_B / n
Covariance is then M_2 / (n - ddof); the accumulator also merges the
third and fourth moments exactly, so a stream can be shrunk (Ledoit–Wolf,
OAS) without a second pass.
.. [#welford1962] Welford (1962), Note on a method for calculating corrected sums of squares and products. .. [#chan1979] Chan, Golub & LeVeque (1979), Updating formulae and a pairwise algorithm for computing sample variances.
Ported from plumax 0.1.2 at 28f257e (src/plumax/matched_filter/streaming.py); the
third and fourth moments, non-finite skipping and streaming shrinkage come from
geotoolz (radtrax#26).
WelfordAccumulator
dataclass
¶
Online mean, covariance and fourth moment for (n_samples, n_bands) streams.
Use :meth:update to feed one batch at a time; :meth:merge to combine
two accumulators computed on disjoint subsets; :meth:mean,
:meth:covariance and :meth:fourth_moment to finalise. Rows with a
non-finite band are skipped, as in the batch estimators.
Besides M₂ = Σ dₖdₖᵀ (dₖ = xₖ − x̄) it keeps the third-moment
vector M₃ = Σ ‖dₖ‖² dₖ and the scalar M₄ = Σ ‖dₖ‖⁴, so a stream
can be shrunk with Ledoit–Wolf (:func:shrink_covariance needs
M₄ / n). Merging parts X with offsets δ_X = x̄_X − x̄ from
the pooled mean is exact::
M₂ = Σ_X [M₂_X + n_X δ_X δ_Xᵀ]
M₃ = Σ_X [M₃_X + tr(M₂_X) δ_X + 2 M₂_X δ_X + n_X ‖δ_X‖² δ_X]
M₄ = Σ_X [M₄_X + 4 δ_Xᵀ M₃_X + 4 δ_Xᵀ M₂_X δ_X
+ 2 ‖δ_X‖² tr(M₂_X) + n_X ‖δ_X‖⁴]
(expand ‖d + δ‖² with d = x − x̄_X, Σ d = 0). Ported from
geotoolz's accumulator (radtrax#26).
Attributes¶
n_bands Number of spectral bands. count Running sample count.
update
¶
update(batch: Float[ArrayLike, '*batch B']) -> WelfordAccumulator
Ingest a batch of pixel spectra (shape (n_samples, n_bands) or (H, W, n_bands)).
Source code in src/radtrax/retrieval/matched_filter/streaming.py
merge
¶
merge(other: WelfordAccumulator) -> WelfordAccumulator
Combine with another accumulator on disjoint data (exact).
Source code in src/radtrax/retrieval/matched_filter/streaming.py
mean
¶
Mean spectrum of the samples seen so far.
covariance
¶
Covariance M₂ / (count − ddof).
Source code in src/radtrax/retrieval/matched_filter/streaming.py
fourth_moment
¶
m₄ = M₄ / count = (1/n) Σ ‖xₖ − x̄‖⁴, the Ledoit–Wolf input.
Source code in src/radtrax/retrieval/matched_filter/streaming.py
streaming_background
¶
streaming_background(batches: Iterable[Float[ArrayLike, '*_batch B']], *, n_bands: int, ridge: float = 0.0, ddof: int = 0, cov_kind: Literal['empirical', 'ledoit_wolf', 'oas'] = 'empirical') -> tuple[Float[ndarray, ' B'], LinearOperator]
Streamed (μ, Σ) from an iterable of batches.
Convenience wrapper for the common pattern of looping over scenes/tiles.
Returns the final mean and a dense lineax operator for the covariance —
pass the covariance through :func:~radtrax.retrieval.matched_filter.core.apply_image
exactly like any other cov_op.
Parameters¶
batches
Iterable yielding arrays of shape (n_samples, n_bands) or
(H, W, n_bands).
n_bands
Number of spectral bands (declared up front since the iterable can be
lazy).
ridge
Optional diagonal ridge added to the final covariance.
ddof
Delta degrees of freedom for the covariance denominator:
M_2 / (n - ddof). Defaults to 0 (MLE, matches sklearn's
EmpiricalCovariance, LedoitWolf, OAS and our
:func:estimate_cov_lowrank), so
:func:~radtrax.retrieval.matched_filter.core.matched_filter_snr and
:func:~radtrax.retrieval.matched_filter.core.detection_threshold
give the same scaling whether the covariance came from a streaming
pass or a batch estimator. Set ddof=1 to match np.cov /
sample-covariance conventions instead.
cov_kind
"empirical" (default), or "ledoit_wolf" / "oas" to shrink
the streamed covariance toward a scaled identity with
:func:~radtrax.retrieval.matched_filter.background.shrink_covariance.
With ddof=0 the shrunk matrix equals scikit-learn's estimator on
the concatenated batches; a non-zero ddof rescales it by
n / (n − ddof) (the intensity is scale-free).
Source code in src/radtrax/retrieval/matched_filter/streaming.py
target¶
Target signature construction from the radiance forward model.
The matched-filter target t(ν) is the spectral signature that a unit
plume enhancement would leave in the observed radiance. Because we already
have a JAX :class:radtrax.retrieval.var3d.RadianceObservationModel,
we do not reimplement the Beer–Lambert derivation from scratch (1379 lines
in the original snippet). Instead:
- Linear target is a directional derivative
∂L/∂VMRat the background state, obtained by a single :func:jax.jvpcall — robust, cheap, and exactly consistent with the forward model used by the 3D-Var retrieval. -
Nonlinear target is the finite-amplitude response
L(x_b + α·dx) − L(x_b), which captures Beer–Lambert saturation for strong plumes. For smallαthis reduces toα · t_linearand the MF score becomes scale-invariant to that choice. -
Unit-absorption target :func:
unit_absorption_targetis∂ ln y / ∂ΔXper pixel on the layered :class:radtrax.ForwardModel(radtrax#37): the scene's own target, replacing a MODTRAN table.
Ported from plumax 0.1.2 at 28f257e (src/plumax/matched_filter/target.py); the read-out
pixel follows a GSD since radtrax#80.
linear_target_from_obs
¶
linear_target_from_obs(obs: RadianceObservationModel, vmr_background: Float[Array, 'H W'], *, pattern: Pattern | Float[ArrayLike, 'H W'] = 'uniform', pixel: tuple[int, int] | None = None, linear_forward: bool = False) -> Float[Array, ' B']
Matched-filter target via the tangent-linear of the forward operator.
Computes
.. math:: t = \frac{\partial}{\partial \alpha} H(x_b + \alpha \, \delta x) \Big|_{\alpha=0}
at a single output pixel. For the default pattern='uniform' this
captures the spectral signature of a uniform VMR enhancement across the
scene (appropriate when the plume fills several PSF footprints). For
pattern='impulse' it captures the instrument's point response to a
single-pixel enhancement (appropriate when the plume is subpixel or
compact compared to the PSF).
Parameters¶
obs
The JAX observation model.
vmr_background
Background VMR field, shape (H, W). Typically
jnp.full((H, W), x_ref) where x_ref == obs.vmr_reference.
pattern
'uniform' (default), 'impulse', or an explicit 2-D perturbation
pattern of the same shape as vmr_background.
pixel
Pixel (i, j) of the VMR grid: the impulse location, and the
place the signature is read — at the output pixel containing it,
(i // f, j // f) for a GSD of factor f. Defaults to the scene
centre (or a custom pattern's peak). pattern and pixel must be
concrete (static, or closed over under jax.jit).
linear_forward
If True, use the linearised forward 1 - Δτ instead of
exp(-Δτ). For small vmr_background the two agree to within
Beer–Lambert curvature.
Returns¶
Float[Array, "B"]
Target signature of length obs.n_bands.
Source code in src/radtrax/retrieval/matched_filter/target.py
nonlinear_target_from_obs
¶
nonlinear_target_from_obs(obs: RadianceObservationModel, vmr_background: Float[Array, 'H W'], *, amplitude: float = 1.0, pattern: Pattern | Float[ArrayLike, 'H W'] = 'uniform', pixel: tuple[int, int] | None = None) -> Float[Array, ' B']
Matched-filter target via a finite-amplitude difference.
Computes t = H(x_b + α·δx) − H(x_b) without linearising Beer–Lambert.
This is the "nonlinear" variant from the original snippets; it captures
the saturation curvature that matters for strong plumes
(α · σ · N · ΔX ≳ 0.1 optical depth).
For small amplitude this reduces to amplitude × linear_target.
Source code in src/radtrax/retrieval/matched_filter/target.py
unit_absorption_target
¶
unit_absorption_target(model: ForwardModel, state: AtmosphericState, surface: Surface, geometry: ViewingGeometry, *, gas: str = 'CH4', profile: Float[ArrayLike, ' L'] | None = None) -> Float[Array, ' B']
Matched-filter target t = ∂ ln y / ∂ΔX at ΔX = 0 [per mol/m²].
One forward-mode derivative of the band log-radiance with respect to a
column enhancement ΔX of gas spread over the layers by
profile (default: the bottom layer, a plume at the surface; see
:meth:~radtrax.atmosphere.AtmosphericState.add_column). The scene
enters through state (water vapour, surface pressure, temperature),
geometry and surface, so t is the scene's own unit
absorption spectrum. It is differentiable in all of them.
Feed it to :func:apply_image with a cube of ln y, or as μ ⊙ t
with a cube of y and background mean μ (∂y/∂ΔX = y t).
:func:linear_target_from_obs and :func:nonlinear_target_from_obs
are the targets of the single-slab
:class:~radtrax.retrieval.var3d.RadianceObservationModel (2-D fields,
PSF and GSD); this one is per pixel on the layered
:class:~radtrax.ForwardModel.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
ForwardModel
|
The forward model. |
required |
state
|
AtmosphericState
|
The background atmosphere. |
required |
surface
|
Surface
|
The surface. |
required |
geometry
|
ViewingGeometry
|
Sun and sensor directions. |
required |
gas
|
str
|
The plume's gas. |
'CH4'
|
profile
|
Float[ArrayLike, ' L'] | None
|
Relative weight of the plume per layer, |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' B']
|
|
Float[Array, ' B']
|
absorbs. |
Source code in src/radtrax/retrieval/matched_filter/target.py
io¶
xarray glue: apply the matched filter to DataArrays and multi-file stores.
The MF kernel in :mod:.core is pure JAX and expects plain arrays. For real
data workflows the scene is usually an :class:xarray.DataArray with named
dims (y, x, band), coordinates, and possibly lazy Dask backing.
These helpers thread the xarray metadata through while the compute stays in
JAX:
- :func:
apply_image_xarray— wraps :func:apply_imagein :func:xarray.apply_ufuncso band-last DataArrays produce a score DataArray with(y, x)dims and preserved spatial coordinates. Dask-backed inputs are evaluated lazily with chunk-wise parallelism. - :func:
open_multi_scene— iterate over a sequence of scene files with :func:xarray.open_dataset, yielding(n_samples, n_bands)numpy batches withband_dimmoved to the last axis. Each dataset is closed as soon as its batch has been yielded (or when the generator is stopped early), so file handles do not leak even when iteration is partial. Feeds naturally into :class:~radtrax.retrieval.matched_filter.streaming.WelfordAccumulator.
Ported from plumax 0.1.2 at 28f257e (src/plumax/matched_filter/io.py); only import paths changed.
apply_image_xarray
¶
apply_image_xarray(cube: DataArray, mean: Float[ArrayLike, ' B'], cov_op: LinearOperator, target: Float[ArrayLike, ' B'], *, band_dim: str = 'band', dask: str = 'parallelized') -> DataArray
Apply the matched filter to an xarray DataArray.
Parameters¶
cube
Radiance cube with a band-last convention (one dim named band_dim).
Other dims are preserved on the output — typically y, x but any
additional dims (e.g. time) pass through unchanged.
mean, cov_op, target
Same semantics as :func:radtrax.retrieval.matched_filter.core.apply_image.
band_dim
Name of the spectral dimension. Default 'band'.
dask
Forwarded to :func:xarray.apply_ufunc:
'parallelized' (default — chunk-wise) or 'allowed'. When
'parallelized' is used, the spectral axis is transparently
rechunked into a single chunk per block (allow_rechunk=True)
because the MF kernel takes the full spectrum at each pixel; this
lets common zarr/netCDF files with a chunked spectral axis flow
through without manual rechunking.
Returns¶
xr.DataArray
Score DataArray with band_dim collapsed.
Source code in src/radtrax/retrieval/matched_filter/io.py
open_multi_scene
¶
open_multi_scene(paths: str | Iterable[str], *, band_dim: str = 'band', variable: str | None = None, **open_kwargs) -> Iterator[Float[ndarray, 'N B']]
Yield (n_pixels, n_bands) batches from a sequence of scene files.
Iterates one file at a time with :func:xarray.open_dataset, closes each
dataset as soon as its scene has been yielded (try/finally-bracketed
so handles are released even if the generator is stopped early), and
yields a flattened 2-D numpy batch suitable for
:class:~radtrax.retrieval.matched_filter.streaming.WelfordAccumulator.update.
Note¶
Each yielded batch is a materialised numpy array — the current scene's
values are loaded eagerly. This is fine for scenes that individually fit
in memory (the Welford aggregator's usual operating regime); for truly
out-of-core per-scene processing, use the lazy DataArray directly with
:func:apply_image_xarray (which supports Dask chunks) instead of this
helper.
Parameters¶
paths
A glob string (e.g. "scenes/*.nc") or an iterable of paths.
band_dim
Name of the spectral dimension on the stored DataArray. Does not
have to be the last axis in the file — each scene is transposed so
band_dim becomes the last axis before flattening.
variable
If a file contains multiple variables, select this one. If None,
uses the first data variable.
**open_kwargs
Forwarded to :func:xarray.open_dataset.
Source code in src/radtrax/retrieval/matched_filter/io.py
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 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 | |
3D-Var¶
Variational methane retrieval: the radiance observation operator, background covariances, control transforms, solvers and diagnostics. Theory: 3D-Var.
var3d
¶
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¶
Submodules are imported lazily (PEP 562), only on first attribute access.
Where this lives (decision D2 of the radtrax backlog): the retrieval moves here with its radiance observation operator, which is radiative transfer. Its solvers are generic variational data assimilation, which is vardax's job; whether they move there waits for the rule on whether GeoModels may import GeoML packages (package-boundaries open questions 1 and 5).
Ported from plumax 0.1.2 at 28f257e (plumax.assimilation); only import
paths changed.
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)
▼ BandInstrument (band integration, per pixel)
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).
The band integration is :class:~radtrax.instrument.BandInstrument, the one
JAX band product in radtrax, mapped over pixels with eqx.filter_vmap; its
spectral shift is a leaf, so a 3D-Var retrieval can estimate it too
(radtrax#95). The high-resolution axis is the instrument's wavenumber grid,
ascending.
Ported from plumax 0.1.2 at 28f257e (src/plumax/assimilation/obs_operator.py); its frozen
dataclasses became Equinox modules in radtrax#58.
RadianceObservationModel
¶
RadianceObservationModel(absorption_coeff_hr: Float[ArrayLike, ' Ghr'], instrument: BandInstrument | Float[ArrayLike, 'B Ghr'], vmr_reference: Float[ArrayLike, ''] = 0.0, psf: PointSpreadFunction | None = None, gsd: GroundSamplingDistance | None = None)
Bases: Module
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
on instrument.nu_cm (ascending wavenumber), shape (n_lambda,).
Captures everything except the VMR field, so the forward map is just
L_hr(i,j,ν) = exp(-a(ν) · ΔVMR(i,j)).
instrument : BandInstrument
The band integration on the same wavenumber grid, (n_lambda,) →
(n_bands,), applied per pixel; its shift_cm (0 by default) is a
differentiable leaf.
vmr_reference : Array
Scalar background VMR, stored as an array so it is traced, not static,
under eqx.filter_jit and differentiable with eqx.filter_grad. 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:radtrax.instrument.psf.
None skips that stage — convenient for hyperspectral tests where
the spectral cube is already at sensor resolution.
Source code in src/radtrax/retrieval/var3d/obs_operator.py
srf_matrix
property
¶
Deprecated: the response in ascending wavelength, instrument.response[:, ::-1].
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', shift_cm: float = 0.0) -> 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: its wavelengths
srf.wavelengths_hr_nm, reversed into ascending wavenumber
ν = 10⁷ / λ, are the grid of both a(ν) and the
:class:~radtrax.instrument.BandInstrument built by
:meth:BandInstrument.from_srf
<radtrax.instrument.BandInstrument.from_srf>, so the 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 product silently
mixed non-corresponding bins — self-consistent for twin retrievals
but physically wrong for anything cross-validated.
shift_cm is the instrument's spectral shift δ [cm⁻¹], 0 by
default (the band integration of earlier revisions).
Source code in src/radtrax/retrieval/var3d/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/radtrax/retrieval/var3d/obs_operator.py
make_forward
¶
Return a closure vmr_field → obs with the model captured.
The closure composes directly with jax.value_and_grad and the
optimisers, which differentiate with respect to the state only. To
differentiate with respect to the model itself (its SRF, its PSF
kernel), pass the model as an argument instead:
eqx.filter_grad(lambda m, x: loss(m.forward(x))).
jax.jit(model.forward) no longer works: the model holds arrays, so
it is not hashable (it was a hashable frozen dataclass in plumax). Use
eqx.filter_jit(model.forward) or jax.jit(model.make_forward()).
Source code in src/radtrax/retrieval/var3d/obs_operator.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:~radtrax.retrieval.var3d.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.
Ported from plumax 0.1.2 at 28f257e (src/plumax/assimilation/background.py); only import paths changed.
build_diagonal_background
¶
build_diagonal_background(variance: Float[ArrayLike, ''] | Float[ArrayLike, ' N'], n_pixels: int | None = None) -> LinearOperator
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/radtrax/retrieval/var3d/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/radtrax/retrieval/var3d/background.py
build_lowrank_background
¶
build_lowrank_background(*, samples: Float[ArrayLike, 'S N'], 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/radtrax/retrieval/var3d/background.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.
Ported from plumax 0.1.2 at 28f257e (src/plumax/assimilation/control.py); its frozen
dataclasses became Equinox modules in radtrax#58.
IdentityTransform
¶
Bases: Module
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
¶
Bases: Module
δ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
¶
from_background(background: LinearOperator) -> WhiteningTransform
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/radtrax/retrieval/var3d/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/radtrax/retrieval/var3d/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/radtrax/retrieval/var3d/control.py
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:~radtrax.retrieval.var3d.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.
Ported from plumax 0.1.2 at 28f257e (src/plumax/assimilation/cost.py); its frozen
dataclasses became Equinox modules in radtrax#58.
Cost
¶
Bases: Module
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.
The observation, background and forward model live inside the closures,
not as array leaves, so a Cost is a static bundle of functions: one
compilation per build_cost_* call, and not differentiable as data.
Differentiate with respect to the model through make_forward's model
argument instead (see RadianceObservationModel.make_forward).
build_cost_x
¶
build_cost_x(*, forward_fn: ForwardFn, background_op: AbstractLinearOperator, obs_inv_variance: ObsInvVariance, background_state: Float[ArrayLike, 'H W'], observation: Float[ArrayLike, 'h w B'], 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/radtrax/retrieval/var3d/cost.py
build_cost_xi
¶
build_cost_xi(*, forward_fn: ForwardFn, whitening: WhiteningTransform | IdentityTransform, obs_inv_variance: ObsInvVariance, background_state: Float[ArrayLike, 'H W'], observation: Float[ArrayLike, 'h w B'], 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:~radtrax.retrieval.var3d.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/radtrax/retrieval/var3d/cost.py
finite_difference_grad
¶
finite_difference_grad(fn: Callable[[Float[Array, '*shape']], Float[Array, '']], x: Float[ArrayLike, '*shape'], *, eps: float = 1e-06) -> Float[ndarray, '*shape']
Central finite differences — used only by tests to validate jax.grad.
O(N) function evaluations, so keep x.size small.
Source code in src/radtrax/retrieval/var3d/cost.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:~radtrax.retrieval.var3d.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.
Ported from plumax 0.1.2 at 28f257e (src/plumax/assimilation/solve.py); its frozen
dataclasses became Equinox modules in radtrax#58.
SolveResult
¶
Bases: Module
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: Float[ArrayLike, ' N'], *, 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/radtrax/retrieval/var3d/solve.py
run_gauss_newton
¶
run_gauss_newton(*, residual_fn: Callable[[Float[Array, ' N']], Float[Array, ' M']], initial_state: Float[ArrayLike, ' N'], 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/radtrax/retrieval/var3d/solve.py
run_dual_psas
¶
run_dual_psas(*, forward_fn: ForwardFn, background_op: AbstractLinearOperator, obs_inv_variance: float, background_state: Float[ArrayLike, 'H W'], observation: Float[ArrayLike, 'h w B'], 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/radtrax/retrieval/var3d/solve.py
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 218 219 220 221 222 223 224 225 226 227 228 229 230 | |
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.
Ported from plumax 0.1.2 at 28f257e (src/plumax/assimilation/diagnostics.py); only import paths changed.
reduced_chi_squared
¶
reduced_chi_squared(*, forward_fn: ForwardFn, estimated_state: Float[ArrayLike, 'H W'], observation: Float[ArrayLike, 'h w B'], obs_inv_variance: ObsInvVariance) -> 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/radtrax/retrieval/var3d/diagnostics.py
degrees_of_freedom_for_signal
¶
degrees_of_freedom_for_signal(*, hessian_vector_product: Callable[[StateVector], StateVector], 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:radtrax.retrieval.var3d.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:radtrax.retrieval.var3d.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/radtrax/retrieval/var3d/diagnostics.py
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 144 145 146 147 148 149 150 151 152 153 154 155 156 | |
posterior_covariance_proxy
¶
posterior_covariance_proxy(*, hessian_vector_product: Callable[[StateVector], StateVector], state_size: int, whitening: WhiteningTransform | None = None, cg_rtol: float = 1e-06, cg_atol: float = 1e-09, cg_max_steps: int = 200) -> Callable[[StateVector], StateVector]
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:radtrax.retrieval.var3d.cost.build_cost_x); the CG solve returnsHess⁻¹ v = Bₐ vdirectly.- a :class:
~radtrax.retrieval.var3d.control.WhiteningTransform— a whitened Hessian (from :func:radtrax.retrieval.var3d.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)).
Source code in src/radtrax/retrieval/var3d/diagnostics.py
MBMP¶
The Sentinel-2 multi-band multi-pass methane signal, radtrax · 2's multi-pass family with per-pass scale factors (SBMP, MBSP, MBMP) and the single-pass SWIR
contrast, as differentiable JAX functions; the GeoTensor operators are
MBMP, SBMP and MBSP in radtrax.geo.
The multi-band multi-pass (MBMP) methane signal, and a single-pass prefilter.
Sentinel-2's band 12 (~2200 nm) sits in strong methane absorption and band 11 (~1600 nm) in weak; a plume depresses band 12 relative to band 11. With a clean-air reference acquisition of the same ground (a second pass), the log band ratio of each pass removes the surface, and their difference removes what the passes share:
.. math:: s = \ln\frac{R_{11} + ε}{R_{12} + ε} - \ln\frac{R'{11} + ε}{R'} + ε
where :math:R_b is band :math:b's radiance or reflectance on the
observed pass and :math:R'_b on the reference pass. This is the MBMP log
signal of Varon et al. (2021) without their per-pass scale factors: a
scene-wide change between passes (a water-vapour or illumination change)
stays in it as a constant offset (radtrax · 2, the GeoStack design note,
found 1 % water vapour reads as such an offset). geotoolz shipped it as
plume.SBMP; radtrax · 2 shows it is an MBMP, so it is named that here.
radtrax · 2 ("Three ways to remove the surface") scales each pass by a
factor fitted through the origin on background pixels,
c = Σ x y / Σ x² (:func:scale_factor), and defines the family
(radtrax#111):
- :func:
sbmp_signal, one band, two passes:−ln(c_s R₁₂ / R′₁₂), withR′₁₂ ≈ c_s R₁₂; - :func:
mbsp_signal, two bands, one pass:−ln(c R₁₂ / R₁₁), withR₁₁ ≈ c R₁₂; - :func:
mbmp_signal, two bands, two passes: the MBSP signal of the observed pass minus that of the reference, which is the log signal above minusln(c / c′); without scale factors it is the log signal.
Each is an observable g to invert through a band table
(:class:radtrax.retrieval.band_table.BandTable: τ₁₂ for SBMP,
τ₁₂ − τ₁₁ for the others). Fit the factors on background pixels: a fit
over a plume biases them (radtrax · 2: 11 % of the mass), and
:func:background_mask grows a first-pass detection into one.
Without a reference pass, :func:swir_contrast is the normalised contrast
:math:(R_{11} - R_{12}) / (R_{11} + R_{12} + ε), a cheap detection
prefilter (Ehret et al., 2022), not a column retrieval.
Negative values (noisy radiances) are clipped to zero before either
formula. Both are JAX and differentiable away from that clip; with ε > 0
gradients stay finite at zero too (~1/ε). A band at zero saturates the
log signal at about ±ln(R/ε) (±22 for ε = 1e-10), so callers
should mask such pixels (:class:radtrax.geo.MBMP does); ε = 0 gives
NaN there.
mbmp_log_signal
¶
mbmp_log_signal(swir1: Float[ArrayLike, '*#shape'], swir2: Float[ArrayLike, '*#shape'], ref_swir1: Float[ArrayLike, '*#shape'], ref_swir2: Float[ArrayLike, '*#shape'], *, eps: float = 1e-10) -> Float[Array, '*#shape']
MBMP log signal ln(R₁₁/R₁₂) − ln(R′₁₁/R′₁₂), no scale factors.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
swir1
|
Float[ArrayLike, '*#shape']
|
Band 11 (weakly absorbing) on the observed pass. |
required |
swir2
|
Float[ArrayLike, '*#shape']
|
Band 12 (strongly absorbing) on the observed pass. |
required |
ref_swir1
|
Float[ArrayLike, '*#shape']
|
Band 11 on the clean-air reference pass. |
required |
ref_swir2
|
Float[ArrayLike, '*#shape']
|
Band 12 on the reference pass. |
required |
eps
|
float
|
Guard added to every band before the logs. |
1e-10
|
Returns:
| Type | Description |
|---|---|
Float[Array, '*#shape']
|
The signal, the broadcast shape of the inputs; positive where band 12 |
Float[Array, '*#shape']
|
is depressed relative to the reference (methane), zero where nothing |
Float[Array, '*#shape']
|
changed between passes. |
Examples:
Source code in src/radtrax/retrieval/mbmp.py
swir_contrast
¶
swir_contrast(swir1: Float[ArrayLike, '*#shape'], swir2: Float[ArrayLike, '*#shape'], *, eps: float = 1e-10) -> Float[Array, '*#shape']
Single-pass normalised SWIR contrast (R₁₁ − R₁₂) / (R₁₁ + R₁₂ + ε).
The normalised difference of bands 11 and 12 (the NBR2 index, as in
geoops.indices.nbr2) after clipping at zero, in JAX. A detection
prefilter, not a column retrieval.
Examples:
Source code in src/radtrax/retrieval/mbmp.py
scale_factor
¶
scale_factor(x: Float[ArrayLike, '*shape'], y: Float[ArrayLike, '*shape'], mask: Bool[ArrayLike, '*shape'] | None = None) -> Float[Array, '']
The least-squares c with y ≈ c x through the origin, Σ x y / Σ x².
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
Float[ArrayLike, '*shape']
|
The scaled band or pass. |
required |
y
|
Float[ArrayLike, '*shape']
|
The band or pass it is fitted to, the same shape. |
required |
mask
|
Bool[ArrayLike, '*shape'] | None
|
Pixels to fit on ( |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
|
Examples:
>>> import jax.numpy as jnp
>>> float(scale_factor(jnp.array([1.0, 2.0]), jnp.array([2.0, 4.0])))
2.0
Source code in src/radtrax/retrieval/mbmp.py
sbmp_signal
¶
sbmp_signal(swir2: Float[ArrayLike, '*shape'], ref_swir2: Float[ArrayLike, '*shape'], *, mask: Bool[ArrayLike, '*shape'] | None = None, eps: float = 1e-10) -> Float[Array, '*shape']
Single-band multi-pass signal −ln(c_s R₁₂ / R′₁₂), R′₁₂ ≈ c_s R₁₂ on mask.
Invert through τ₁₂ alone. The factor removes a scene-wide change
between the passes, not a local one (a wetted field reads as methane).
Examples:
>>> import jax.numpy as jnp
>>> r = jnp.array([1.0, 2.0, 3.0])
>>> [
... round(float(v), 6) + 0.0 for v in sbmp_signal(0.9 * r, r)
... ] # dimmer, no plume
[0.0, 0.0, 0.0]
Source code in src/radtrax/retrieval/mbmp.py
mbsp_signal
¶
mbsp_signal(swir1: Float[ArrayLike, '*shape'], swir2: Float[ArrayLike, '*shape'], *, mask: Bool[ArrayLike, '*shape'] | None = None, eps: float = 1e-10) -> Float[Array, '*shape']
Multi-band single-pass signal −ln(c R₁₂ / R₁₁), R₁₁ ≈ c R₁₂ on mask.
Invert through τ₁₂ − τ₁₁. A surface whose band ratio departs from the
scene's reads as methane.
Examples:
>>> import jax.numpy as jnp
>>> r = jnp.array([1.0, 2.0, 3.0])
>>> [
... round(float(v), 6) + 0.0 for v in mbsp_signal(r, 0.8 * r)
... ] # one ratio, no plume
[0.0, 0.0, 0.0]
Source code in src/radtrax/retrieval/mbmp.py
mbmp_signal
¶
mbmp_signal(swir1: Float[ArrayLike, '*shape'], swir2: Float[ArrayLike, '*shape'], ref_swir1: Float[ArrayLike, '*shape'], ref_swir2: Float[ArrayLike, '*shape'], *, mask: Bool[ArrayLike, '*shape'] | None = None, scale_factors: bool = True, eps: float = 1e-10) -> Float[Array, '*shape']
Multi-band multi-pass signal −ln((1 + δ) / (1 + δ′)), δ = c R₁₂ / R₁₁ − 1.
With scale_factors (each pass's c fitted on mask) it is
:func:mbmp_log_signal minus ln(c / c′), which removes a scene-wide
change between the passes; without, it is :func:mbmp_log_signal
itself. Invert through τ₁₂ − τ₁₁.
Examples:
>>> import jax.numpy as jnp
>>> r = jnp.array([1.0, 2.0, 3.0])
>>> s = mbmp_signal(r, 0.8 * r, 1.1 * r, 0.88 * 0.99 * r) # 1 % H₂O change
>>> [round(float(v), 6) + 0.0 for v in s]
[0.0, 0.0, 0.0]
Source code in src/radtrax/retrieval/mbmp.py
background_mask
¶
background_mask(first_pass: Float[ArrayLike, 'H W'], *, threshold: float = 0.03, sigma_px: float = 2.0, level: float = 0.05) -> Bool[ndarray, 'H W']
Background pixels: outside a first-pass detection grown by a Gaussian.
radtrax · 2's mask: pixels whose first-pass ΔΩ exceeds
threshold, blurred by a Gaussian of sigma_px pixels, and every
pixel the blur lifts above level removed. NumPy (SciPy's
gaussian_filter), not differentiable.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
first_pass
|
Float[ArrayLike, 'H W']
|
A first retrieval, e.g. with factors fitted on every
pixel, |
required |
threshold
|
float
|
Detection threshold, in the units of |
0.03
|
sigma_px
|
float
|
Gaussian growth [pixels]. |
2.0
|
level
|
float
|
Blurred detection above which a pixel is not background. |
0.05
|
Returns:
| Type | Description |
|---|---|
Bool[ndarray, 'H W']
|
|
Bool[ndarray, 'H W']
|
in the first pass) is never background. |
Source code in src/radtrax/retrieval/mbmp.py
Band table¶
A band observable g(m[, h], ΔΩ) (one band's τ₁₂, or τ₁₂ − τ₁₁) tabulated against the enhancement and inverted by interpolation, as radtrax · 1 and · 2 do, from any observable or from the layered forward model.
Band-table inversion: a band observable g tabulated against the enhancement.
radtrax · 1 (stage 4) and radtrax · 2 invert a band observable through a table rather than a linear slope, which the curve of growth biases low:
- one band,
g = τ₁₂(ΔΩ) = −ln(T₁₂(Ω_bg + ΔΩ) / T₁₂(Ω_bg))(SBMP); - two bands,
g = τ₁₂ − τ₁₁(MBSP and MBMP).
:class:BandTable holds g on a grid of two-way air-mass factors m
(and, optionally, water-vapour scalings h) against the enhancement
ΔΩ. It interpolates linearly in m and h and piecewise-linearly in
ΔΩ, so the inverse is exact on the same nodes:
invert(table(ΔΩ, m), m) = ΔΩ to rounding. Noise makes g negative off a
plume, so g is extended as an odd function of ΔΩ (as both notes do).
Everything is JAX and differentiable in g, m, h and the table.
Build a table from any observable with :meth:BandTable.from_function (the
notes' monochromatic band transmittances) or from the layered forward model
with :func:band_table_from_model (ΔΩ then in mol/m², h scales the
H₂O profile).
Shapes: M air masses, H water-vapour scalings, D enhancements.
BandTable
¶
BandTable(m_grid: Float[ArrayLike, ' M'], delta_grid: Float[ArrayLike, ' D'], g: Float[ArrayLike, 'M *H D'], h2o_grid: Float[ArrayLike, ' H'] | None = None)
Bases: Module
A band observable g(m[, h], ΔΩ), monotone in ΔΩ, and its inverse.
Attributes:
| Name | Type | Description |
|---|---|---|
m_grid |
Float[Array, ' M']
|
Two-way air-mass factors, increasing, |
delta_grid |
Float[Array, ' D']
|
Enhancements, increasing from 0, |
g |
Float[Array, 'M *H D']
|
The observable, strictly increasing in |
h2o_grid |
Float[Array, ' H'] | None
|
Water-vapour scalings, increasing, |
Examples:
>>> import jax.numpy as jnp
>>> delta = jnp.linspace(0.0, 0.4, 41)
>>> table = BandTable(
... jnp.array([2.0, 3.0]), delta, jnp.stack([0.3 * delta, 0.45 * delta])
... )
>>> round(float(table(0.2, 2.5)), 6) # g at m = 2.5, halfway
0.075
>>> round(float(table.invert(0.075, 2.5)), 6)
0.2
Source code in src/radtrax/retrieval/band_table.py
row
¶
g against ΔΩ at m (and h), linear between the nodes, (D,).
Source code in src/radtrax/retrieval/band_table.py
invert
¶
invert(g: Float[ArrayLike, '*shape'], m: Float[ArrayLike, ''], h2o: Float[ArrayLike, ''] | None = None) -> Float[Array, '*shape']
ΔΩ = g⁻¹(g) at m (and h), odd in g; held at the table's end beyond it.
Source code in src/radtrax/retrieval/band_table.py
from_function
classmethod
¶
from_function(fn: Callable[..., Float[ArrayLike, ' D']], m_grid: Float[ArrayLike, ' M'], delta_grid: Float[ArrayLike, ' D'], h2o_grid: Float[ArrayLike, ' H'] | None = None) -> BandTable
Tabulate fn(m, delta_grid) (or fn(m, delta_grid, h)), each (D,).
Source code in src/radtrax/retrieval/band_table.py
band_table_from_model
¶
band_table_from_model(model: ForwardModel, state: AtmosphericState, surface: Surface, *, bands: Sequence[int], m_grid: Float[ArrayLike, ' M'], delta_grid: Float[ArrayLike, ' D'], h2o_grid: Float[ArrayLike, ' H'] | None = None, gas: str = 'CH4', h2o_gas: str = 'H2O', mu: float = 1.0) -> BandTable
A :class:BandTable from the layered forward model.
τ_b(ΔΩ) = −ln(y_b(ΔΩ) / y_b(0)) with ΔΩ [mol/m²] of gas added
by :meth:~radtrax.atmosphere.AtmosphericState.add_column;
g = τ_{b₀} for one band, g = τ_{b₀} − τ_{b₁} for two. Each
m is a geometry with the sensor at mu and the sun at
μ₀ = 1 / (m − 1/μ); each h scales the state's h2o_gas
profile.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
ForwardModel
|
The forward model. |
required |
state
|
AtmosphericState
|
The background state. |
required |
surface
|
Surface
|
The surface. |
required |
bands
|
Sequence[int]
|
One band index (SBMP) or two, strong then weak (MBSP, MBMP). |
required |
m_grid
|
Float[ArrayLike, ' M']
|
Two-way air-mass factors, each at least |
required |
delta_grid
|
Float[ArrayLike, ' D']
|
Enhancements [mol/m²], increasing from 0, |
required |
h2o_grid
|
Float[ArrayLike, ' H'] | None
|
Water-vapour scalings, |
None
|
gas
|
str
|
The enhanced gas. |
'CH4'
|
h2o_gas
|
str
|
The water-vapour gas the scalings apply to. |
'H2O'
|
mu
|
float
|
Cosine of the viewing zenith angle. |
1.0
|
Returns:
| Type | Description |
|---|---|
BandTable
|
The table. |
Source code in src/radtrax/retrieval/band_table.py
The absorption-shape test¶
A plume-agnostic retrieval: given two sets of pixels, does the ratio of their paired mean radiances have the shape of a gas's transmittance? One-to-one pairs on bands the gas does not absorb, the ratio spectrum, its fit to the forward model's transmittance, the fitted column ΔX̂ and the misfit D_norm (Xiang et al. 2024). plumax.vetting runs it on plume candidates. Theory: the absorption-shape test.
absorption
¶
The absorption-shape test: does a ratio spectrum have a gas's shape?
A plume-agnostic retrieval on arrays. Given two sets of pixels of one
radiance cube (in-pixels and out-pixels, as flat indices), it pairs them one
to one on bands the gas does not absorb, divides the pairs' mean radiances
into a ratio spectrum, fits that ratio with a smooth continuum times the
gas's transmittance from radtrax.ForwardModel, and returns the fitted
column ΔX̂ and the misfit D_norm (Xiang et al.'s eq. 11): how far the
ratio's shape is from the transmittance's. It knows nothing of plumes or
candidates; plumax.vetting chooses the pixel sets from plume candidates
and decides on the scores. Theory: the absorption-shape test page.
window:AbsorptionWindow, a gas's fitting and pairing bands and its matched-filter background cut, with EMIT's CH₄ and CO presets (#155).transmittance:transmittance_table, the band transmittance of a column enhancement fromForwardModel, exact or constant-coefficient (#154).pairing:pair_pixelsandratio_spectrum, one-to-one pairs of two flat pixel-index sets on the pairing bands and the ratio of their mean radiances (#149).fit:TransmittanceFit,fit_transmittanceandd_norm, the profile fit of the ratio to a Legendre continuum times the transmittance, and its misfit score (#150).
window¶
A gas's absorption window: its fitting bands, its pairing bands and its background cut.
A window is configuration, not state, so it is a frozen dataclass, as
radtrax.spectroscopy.hitran's configs are. Its masks are NumPy booleans:
they select bands before anything is traced, and nothing differentiates
through them.
AbsorptionWindow
dataclass
¶
A gas's fitting bands, the bands kept out of pairing, and the background cut.
Ranges are in nm and inclusive at both ends, as in the reference code's
configuration (pv/config/config.yaml). Pairing uses every band outside
absorbing_nm, so a window whose fitting ranges lie inside its
absorbing ranges has disjoint fitting and pairing masks.
Attributes:
| Name | Type | Description |
|---|---|---|
gas |
str
|
The gas whose enhancement is fitted, as named in the forward
model's state ( |
fit_nm |
Ranges
|
The |
absorbing_nm |
Ranges
|
The |
mf_background_mol_m2 |
float
|
The matched-filter score, in mol/m², below
which a pixel counts as background; the absorption-shape test
does not use it, the code that chooses the out-pixels does
( |
Raises:
| Type | Description |
|---|---|
ParameterError
|
If |
Examples:
>>> import numpy as np
>>> from radtrax.retrieval.absorption import AbsorptionWindow
>>> window = AbsorptionWindow.ch4_emit()
>>> window.fit_nm, window.absorbing_nm
(((2100.0, 2440.0),), ((1640.0, 1690.0), (2100.0, 2440.0)))
>>> print(f"{window.mf_background_mol_m2:.3e} mol/m²")
1.209e-03 mol/m²
>>> wl = np.array([1500.0, 1650.0, 2100.0, 2300.0, 2440.0, 2450.0])
>>> window.fit_bands(wl)
array([False, False, True, True, True, False])
>>> window.pairing_bands(wl)
array([ True, False, False, False, False, True])
ch4_emit
classmethod
¶
ch4_emit() -> AbsorptionWindow
EMIT's CH₄ window, as the reference code's configuration sets it.
Fits 2100–2440 nm; keeps CH₄'s 1640–1690 and 2100–2440 nm bands out of pairing; counts pixels below 30 ppm·m as background.
Source code in src/radtrax/retrieval/absorption/window.py
co_emit
classmethod
¶
co_emit() -> AbsorptionWindow
EMIT's CO window, from the source report's Table S1 (Růžička et al.).
Fits 2278–2441 nm and keeps the same range out of pairing; counts pixels below 300 ppm·m as background.
Source code in src/radtrax/retrieval/absorption/window.py
fit_bands
¶
The bands inside any fitting range.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
wavelength_nm
|
Float[ArrayLike, ' B']
|
Band centres, in nm, |
required |
Returns:
| Type | Description |
|---|---|
Bool[ndarray, ' B']
|
A boolean mask, |
Source code in src/radtrax/retrieval/absorption/window.py
pairing_bands
¶
The bands outside every absorbing range, which pairing compares.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
wavelength_nm
|
Float[ArrayLike, ' B']
|
Band centres, in nm, |
required |
Returns:
| Type | Description |
|---|---|
Bool[ndarray, ' B']
|
A boolean mask, |
Source code in src/radtrax/retrieval/absorption/window.py
transmittance¶
The gas's transmittance: a scene's band transmittance on a grid of columns.
E_b(ΔX) = y_b(x + ΔX e) / y_b(x) from the same :class:radtrax.ForwardModel
the matched filter's target comes from (:func:radtrax.unit_absorption_target),
so the fit and the filter see the same gas and the curve of growth is built
in. It replaces the reference code's MODTRAN tables. Theory: the
absorption-shape test page, section 2.
Shapes: D column enhancements, B bands, L layers.
transmittance_table
¶
transmittance_table(model: ForwardModel, state: AtmosphericState, geometry: ViewingGeometry, delta_grid: Float[ArrayLike, ' D'], *, gas: str = 'CH4', profile: Float[ArrayLike, ' L'] | None = None, linear: bool = False) -> Float[Array, 'D B']
The band transmittance E_b(ΔX) of a column enhancement, on a grid.
.. math:: E_b(\Delta X) = \frac{y_b(x + \Delta X\, e)}{y_b(x)}, \qquad E^{\text{lin}}_b(\Delta X) = e^{-\varepsilon_b \Delta X}, \quad \varepsilon_b = -\left.\frac{\partial \ln y_b}{\partial \Delta X} \right|_0 ,
with y the model's band values for a unit Lambertian albedo, ΔX
added by :meth:~radtrax.atmosphere.AtmosphericState.add_column with
weights e from profile, and ε_b = −t_b from
:func:radtrax.unit_absorption_target. Beer–Lambert radiance is linear
in a Lambertian albedo, so the albedo cancels in the ratio exactly per
wavenumber, and after band integration to the extent the albedo is flat
across a band's response. The background y(x) is its own forward
model run at ΔX = 0, so the grid need not contain 0; a row with
ΔX = 0 is exactly 1. E is decreasing and
convex in ΔX, and E^lin ≤ E (Jensen): a constant-coefficient fit
of a strong enhancement reads its column low.
One forward model per grid point and one for the background,
jax.vmap over them. It is
differentiable in the model's leaves (the spectral shift, say), the
state and the geometry.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
ForwardModel
|
The forward model. |
required |
state
|
AtmosphericState
|
The background atmosphere. |
required |
geometry
|
ViewingGeometry
|
Sun and sensor directions. |
required |
delta_grid
|
Float[ArrayLike, ' D']
|
Column enhancements |
required |
gas
|
str
|
The enhanced gas. |
'CH4'
|
profile
|
Float[ArrayLike, ' L'] | None
|
Relative weight of the enhancement per layer, |
None
|
linear
|
bool
|
Return the constant-coefficient model |
False
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'D B']
|
|
Raises:
| Type | Description |
|---|---|
MissingKeyError
|
If the state carries no |
ShapeError
|
If |
GridError
|
If a concrete |
Examples:
>>> import jax.numpy as jnp
>>> from radtrax import ForwardModel
>>> from radtrax.atmosphere import ViewingGeometry, afgl_us_standard
>>> from radtrax.instrument import BandInstrument
>>> from radtrax.solvers import BeerLambert
>>> from radtrax.spectroscopy import CrossSectionTable, TableOpacity
>>> nu = jnp.array([4300.0, 4300.1, 4300.2])
>>> sigma = jnp.array([1e-21, 4e-21, 1e-21]) # cm²/molecule
>>> table = CrossSectionTable(
... jnp.log(jnp.array([1e3, 1.2e5])),
... jnp.array([200.0, 310.0]),
... jnp.broadcast_to(sigma, (2, 2, 3)),
... )
>>> model = ForwardModel(
... TableOpacity(nu, {"CH4": table}),
... BeerLambert(),
... BandInstrument(nu, jnp.array([[1.0, 0.0, 0.0], [0.0, 0.5, 0.5]])),
... )
>>> state = afgl_us_standard(z_top_km=3.0, gases=["CH4"])
>>> geometry = ViewingGeometry(1.0, 1.0)
>>> grid = jnp.array([0.0, 0.1, 0.2]) # mol/m²
>>> exact = transmittance_table(model, state, geometry, grid)
>>> lin = transmittance_table(model, state, geometry, grid, linear=True)
>>> exact.shape, bool(jnp.all(exact[0] == 1.0))
((3, 2), True)
>>> # One wavenumber: E is exactly exponential; two: the curve bends.
>>> float(jnp.max(jnp.abs(exact[:, 0] - lin[:, 0]))) < 1e-12
True
>>> bool(jnp.all(exact[1:, 1] > lin[1:, 1]))
True
Source code in src/radtrax/retrieval/absorption/transmittance.py
47 48 49 50 51 52 53 54 55 56 57 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 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | |
pairing¶
One-to-one spectral pairs and the ratio of their mean radiances.
The pairing and ratio steps of the absorption-shape test (steps 3–4 of
Xiang et al.'s method, as the reference code runs them,
pv/src/pv/pv.py): given two pixel sets, each in-pixel is paired with one
out-pixel, the assignment minimising the total Euclidean distance between
their radiances on bands the gas does not absorb, and the best-matched half
of the pairs is kept; the ratio spectrum divides the kept pairs' mean
in-pixel radiance by their mean out-pixel radiance. Which pixels form the
two sets (a plume candidate's seeds and its background pool, say) is the
caller's choice: plumax.vetting makes it.
Pairing is NumPy and SciPy, off the gradient path (an assignment is
discrete); the distance it minimises is the one private step
_pairing_distance. The ratio is JAX and differentiable in the cube.
Shapes: n_in in-pixels, n_out out-pixels, N kept pairs.
pair_pixels
¶
pair_pixels(cube: Float[ArrayLike, 'H W B'], in_pixels: Int[ArrayLike, ' n_in'], out_pixels: Int[ArrayLike, ' n_out'], pairing_bands: Bool[ArrayLike, ' B'], *, keep: float = 0.5) -> tuple[Int[ndarray, ' N'], Int[ndarray, ' N']]
One-to-one pairs of in-pixels and out-pixels, the best-matched kept.
With s(p) pixel p's radiance on the pairing bands, the in-pixels
I and the out-pixels O, the pairing is the injective assignment
π* = argmin_{π: I → O injective} ∑_{i ∈ I} ‖s(i) − s(π(i))‖₂,
solved exactly by scipy.optimize.linear_sum_assignment; the
N = int(keep · |I|) pairs of smallest distance are kept. One to one,
so no out-pixel's noise or surface mismatch enters two pairs.
The two sets are flat indices into the image, r · W + c for row
r and column c; a boolean (H, W) mask converts with
np.flatnonzero(mask). Nothing here knows what the sets are.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cube
|
Float[ArrayLike, 'H W B']
|
Radiance, |
required |
in_pixels
|
Int[ArrayLike, ' n_in']
|
The set |
required |
out_pixels
|
Int[ArrayLike, ' n_out']
|
The set |
required |
pairing_bands
|
Bool[ArrayLike, ' B']
|
The bands compared, |
required |
keep
|
float
|
Fraction of the pairs kept, in (0, 1]; the reference code's
|
0.5
|
Returns:
| Name | Type | Description |
|---|---|---|
Int[ndarray, ' N']
|
|
|
each |
Int[ndarray, ' N']
|
pair |
tuple[Int[ndarray, ' N'], Int[ndarray, ' N']]
|
|
|
tuple[Int[ndarray, ' N'], Int[ndarray, ' N']]
|
kept. No index repeats in either. |
Raises:
| Type | Description |
|---|---|
ParameterError
|
If |
ShapeError
|
If |
DegenerateInputError
|
If there are no in-pixels, there are fewer
out-pixels than in-pixels, no band is a pairing band, |
Examples:
>>> import numpy as np
>>> from radtrax.retrieval.absorption import pair_pixels
>>> cube = np.array([[[1.0, 9.0], [3.0, 9.0], [3.1, 0.0], [0.8, 0.0]]])
>>> in_pixels, out_pixels = np.array([0, 1]), np.array([2, 3])
>>> bands = np.array([True, False])
>>> pair_pixels(cube, in_pixels, out_pixels, bands, keep=1.0)
(array([1, 0]), array([2, 3]))
>>> pair_pixels(cube, in_pixels, out_pixels, bands)
(array([1]), array([2]))
Source code in src/radtrax/retrieval/absorption/pairing.py
51 52 53 54 55 56 57 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 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | |
ratio_spectrum
¶
ratio_spectrum(cube: Float[ArrayLike, 'H W B'], in_idx: Int[ArrayLike, ' N'], out_idx: Int[ArrayLike, ' N']) -> Float[Array, ' B']
The ratio of the pairs' mean in-pixel and mean out-pixel radiances.
R(λ) = ∑_n w_n(λ) L_in,n(λ) / ∑_n w_n(λ) L_out,n(λ), with
w_n(λ) = 1[L_in,n(λ) and L_out,n(λ) finite, L_out,n(λ) > 0]: a pair
is left out of a band where either of its radiances is unusable (a NaN
or infinite one, or an out-pixel radiance that is not positive). When
every pair is usable, R is the ratio of the means,
(1/N) ∑_n L_in,n / (1/N) ∑_n L_out,n. Averaging before dividing
keeps the ratio's noise bias at the size of the mean's, not of one
pixel's.
Differentiable in cube (the weights are piecewise constant; a
radiance left out has a zero gradient, and does not make its partner's
NaN) and
traceable under jax.jit in cube; the indices are NumPy, checked
eagerly, and enter as constants.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cube
|
Float[ArrayLike, 'H W B']
|
Radiance, |
required |
in_idx
|
Int[ArrayLike, ' N']
|
Flat indices of the pairs' in-pixels, |
required |
out_idx
|
Int[ArrayLike, ' N']
|
Flat indices of their out-pixels, |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, ' B']
|
|
Float[Array, ' B']
|
there is zero, not NaN). |
Raises:
| Type | Description |
|---|---|
ShapeError
|
If |
DegenerateInputError
|
If there are no pairs. |
ParameterError
|
If an index is outside |
Examples:
>>> import numpy as np
>>> from radtrax.retrieval.absorption import ratio_spectrum
>>> cube = np.array([[[1.0, 2.0], [3.0, 4.0], [2.0, 8.0], [2.0, 0.0]]])
>>> print(ratio_spectrum(cube, np.array([0, 1]), np.array([2, 3])))
[1. 0.25]
Source code in src/radtrax/retrieval/absorption/pairing.py
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 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | |
fit¶
The profile fit of the ratio spectrum and its score, D_norm.
The fit and the score of the absorption-shape test (step 5 of Xiang et
al.'s method): fit the pair ratio with a smooth continuum times the gas's
transmittance, R(λ) ≈ P(λ; c) E(λ; ΔX), and score the misfit with their
eq. 11. The continuum is a Legendre series on the window
(the basis of radtrax.surface.PolynomialAlbedo); for fixed ΔX the
model is linear in c, so ĉ(ΔX) is a least-squares solve (lineax's
QR) and ΔX̂ the grid point of least residual, refined between the
nodes by a parabola through the residuals: no starting point, the global
minimum over the grid. Theory: the absorption-shape test page, section 4.
The fit weights each band by _band_weights: 1 where the ratio is finite,
0 where it is not (ratio_spectrum returns NaN in a band with no usable
pair). That is ordinary least squares on the usable bands, and the one step
a noise model for the ratio (radtrax#165) would replace.
Shapes: B bands of the fitting window, D column enhancements of the
grid, K continuum coefficients (degree K − 1).
TransmittanceFit
¶
Bases: Module
The profile fit of R ≈ P(λ; c) E(λ; ΔX).
Attributes:
| Name | Type | Description |
|---|---|---|
delta |
Float[Array, '']
|
|
coefficients |
Float[Array, ' K']
|
|
continuum |
Float[Array, ' B']
|
|
transmittance |
Float[Array, ' B']
|
|
residual_ss |
Float[Array, '']
|
|
fit_transmittance
¶
fit_transmittance(ratio: Float[ArrayLike, ' B'], table: Float[ArrayLike, 'D B'], delta_grid: Float[ArrayLike, ' D'], wavelength_nm: Float[ArrayLike, ' B'], *, degree: int = 2) -> TransmittanceFit
Fit a Legendre continuum times the transmittance, profiling over ΔX.
With x_λ = 2 (λ − λ_min) / (λ_max − λ_min) − 1 the band centres
mapped onto [−1, 1], V_λk = P_k(x_λ) the Legendre polynomials
(K = degree + 1), A(ΔX) = diag(E(ΔX)) V and w_λ the band
weights (1 where R is finite, else 0),
ĉ(ΔX) = argmin_c Σ_λ w_λ (R(λ) − (A(ΔX) c)_λ)², solved by QR
(lineax.QR) at each grid column;
k = argmin_d RSS(ΔX_d), the grid column of least residual, then
refined between the nodes: ΔX̂ is the vertex of the parabola through
RSS at three neighbouring nodes around k (the first or last three
at a grid end), clipped to their span; E(ΔX̂) interpolates ln E
linearly between the two rows around it (so E > 0), and ĉ is
solved again there. The refined fit replaces the node's only where the
parabola is convex and the refit lowers the residual by more than
rounding (10⁻¹² of ‖R‖²), so a fit exact on a node stays there. For RSS ≈ a u² + b u³ near its minimum (u
the distance to it) and spacing h, the vertex is off by
(b / 2a)(3δ² − h²), δ the middle node's distance to the
minimum: at most |b| h² / a, against h / 2 for the node alone.
The grid should start at 0 (as transmittance_table's does by
default), which imposes ΔX ≥ 0. Bands where ratio is NaN are
left out of the fit; continuum and transmittance are still
evaluated there.
Piecewise differentiable: ΔX̂ and the fields evaluated at it are
differentiable in ratio and table while the node k and the
choice between the node and the refined fit stay put (they jump where
k changes), and their gradient in a NaN band is zero.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ratio
|
Float[ArrayLike, ' B']
|
The ratio spectrum |
required |
table
|
Float[ArrayLike, 'D B']
|
|
required |
delta_grid
|
Float[ArrayLike, ' D']
|
The columns |
required |
wavelength_nm
|
Float[ArrayLike, ' B']
|
The band centres [nm], |
required |
degree
|
int
|
The continuum's degree, |
2
|
Returns:
| Type | Description |
|---|---|
TransmittanceFit
|
The |
TransmittanceFit
|
at |
Raises:
| Type | Description |
|---|---|
ParameterError
|
If |
ShapeError
|
If |
DegenerateInputError
|
If fewer than |
Examples:
>>> import jax.numpy as jnp
>>> from radtrax.retrieval.absorption import fit_transmittance
>>> wl = jnp.linspace(2200.0, 2400.0, 6)
>>> grid = jnp.array([0.0, 0.1, 0.2])
>>> table = jnp.exp(
... -grid[:, None] * jnp.array([1.0, 3.0, 0.5, 4.0, 1.0, 2.0])
... )
>>> ratio = (1.0 + 0.1 * jnp.linspace(-1.0, 1.0, 6)) * table[1]
>>> fit = fit_transmittance(ratio, table, grid, wl, degree=1)
>>> print(f"{fit.delta:.1f}", jnp.round(fit.coefficients, 6))
0.1 [1. 0.1]
Source code in src/radtrax/retrieval/absorption/fit.py
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 144 145 146 147 148 149 150 151 152 153 154 | |
d_norm
¶
d_norm(ratio: Float[ArrayLike, ' B'], fit: TransmittanceFit) -> Float[Array, '']
Xiang et al.'s eq. 11: the fit's misfit relative to the ratio's spread.
With q = R / P(ĉ) the ratio with the continuum divided out,
D_norm = mean_λ |q(λ) − E(λ; ΔX̂)| / mean_λ |q(λ) − q̄|,
means over the bands where R is finite (q̄ is their mean). A
perfect fit gives 0; with no gas (ΔX̂ = 0, E = 1) it is close to
1, and it can exceed 1. Differentiable in ratio (zero gradient in a
NaN band); fit is held fixed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ratio
|
Float[ArrayLike, ' B']
|
The ratio spectrum |
required |
fit
|
TransmittanceFit
|
Its |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
|
Float[Array, '']
|
|
Raises:
| Type | Description |
|---|---|
ShapeError
|
If |
Examples:
>>> import jax.numpy as jnp
>>> from radtrax.retrieval.absorption import TransmittanceFit, d_norm
>>> e = jnp.array([1.0, 0.9, 0.8, 0.9])
>>> fit = TransmittanceFit(
... jnp.array(0.1),
... jnp.array([2.0]),
... jnp.full(4, 2.0),
... e,
... jnp.array(0.0),
... )
>>> print(f"{d_norm(2.0 * e, fit):.3f}")
0.000
>>> print(f"{d_norm(jnp.array([2.0, 1.8, jnp.nan, 2.0]), fit):.3f}")
0.750