Radiative transfer¶
Band-integrated Beer–Lambert forward model, normalised-brightness LUT, and matched-filter retrieval. RTM stack.
The package resolves its public names lazily (PEP 562), so they are documented here from the submodules that define them.
radtran
¶
Band-integrated radiative transfer + matched-filter retrieval.
Bridges the line-by-line :mod:plumax.hapi_lut absorption-LUT
family to the multispectral / hyperspectral retrieval world by providing:
- :mod:
.config— :class:ObservationGeometry, :class:InstrumentSpec. - :mod:
.srf— :class:SpectralResponseFunction(gaussian / rectangular / triangular / custom) for band integration. - :mod:
.forward— exact / Maclaurin / Taylor Beer-Lambert forward models and their normalised (differential) variants, each returning(radiance, Jacobian, transmittance). - :mod:
.nb_lut— pre-tabulated normalised-brightness LUTnB_b(ΔX)plus :func:inject_plumefor scene-injection. - :mod:
.target— matched-filter target-spectrum constructors (nonlinear + linearised). - :mod:
.background— robust trimmed-mean background and low-rank covariance estimators (depends on :mod:scipy.stats). - :mod:
.matched_filter— the coreε̂ = (x−μ)ᵀΣ⁻¹t / (tᵀΣ⁻¹t)at pixel and image scope, plus an SNR helper. - :mod:
.gaussx_solve— structured-operator matched filter using :mod:gaussx'sLowRankUpdate+ Woodbury-backedsolve, for hyperspectral retrievals where theO(n_bands³)dense inverse in :mod:.matched_filterbecomes the bottleneck.
The input LUT is expected to be the :class:xarray.Dataset produced by
:func:plumax.hapi_lut.build_lut_dataset.
Notes¶
radtran is an aggregator: it re-exports symbols from its submodules but
imports them lazily via :pep:562. This means
import plumax(which itself importsradtran) does not pull in :mod:scipyor :mod:gaussxuntil you actually touch :mod:.background/ :mod:.gaussx_solve.from plumax.radtran import build_nb_luttriggers exactly one submodule import (radtran.nb_lut) and stops there.- The
__all__list still drives star-imports and IDE auto-complete, so the lazy loading is invisible to consumers.
config¶
Observation-geometry and instrument configuration for radtran.
The forward models in :mod:plumax.radtran.forward need three
groups of inputs: atmospheric state (T, P, background VMR), a
gas-species LUT (σ(ν, T, P), from :mod:plumax.hapi_lut), and
the viewing geometry and instrument response.
This module factors the latter two into frozen dataclasses:
- :class:
ObservationGeometry— solar/viewing zenith angles, vertical path length, optional AMF override. - :class:
InstrumentSpec— band centres and widths for a synthetic multispectral instrument. Real instruments should build a :class:~plumax.radtran.srf.SpectralResponseFunctionfrom measured SRFs;InstrumentSpecis a convenient constructor for the tutorial notebooks.
BOLTZMANN_J_PER_K and ATM_TO_PA mirror the constants in
:mod:plumax.hapi_lut.beers so the two forward-model stacks agree
on the ideal-gas number density computation.
ObservationGeometry
dataclass
¶
ObservationGeometry(sza_deg: float = 30.0, vza_deg: float = 0.0, path_length_cm: float = 840000.0, amf: float | None = None)
Viewing geometry for a single pixel.
Attributes¶
sza_deg : float
Solar zenith angle [degrees], 0 (overhead sun) to ~90 (grazing).
vza_deg : float
Viewing zenith angle [degrees], 0 (nadir) to ~90 (limb).
path_length_cm : float
Vertical atmospheric path [cm] traversed by the line of sight before
AMF correction. For a single-slab column over the full atmosphere,
a common choice is L = p_surf / (ρ · g) ≈ 8.4 km = 8.4e5 cm
(scale height of dry air at 1 atm, 288 K).
amf : float, optional
Air-mass factor. If None the plane-parallel two-way form
1/cos(SZA) + 1/cos(VZA) is used.
Notes¶
The plane-parallel AMF breaks down past ~75° zenith; beyond that a spherical correction (Chapman function) is needed. For the tutorial regime (SZA, VZA < 60°) the plane-parallel form is within a few percent of the exact value.
air_mass_factor
property
¶
Plane-parallel two-way AMF, or the explicit override if provided.
InstrumentSpec
dataclass
¶
InstrumentSpec(name: str = 'synthetic', band_centers_nm: ndarray = (lambda: np.array([1610.0, 2190.0], dtype=float))(), band_widths_nm: ndarray = (lambda: np.array([90.0, 180.0], dtype=float))(), band_names: tuple[str, ...] = ('B11', 'B12'), srf_type: str = 'gaussian')
Multispectral instrument description.
Attributes¶
name : str
Free-text instrument identifier for dataset metadata.
band_centers_nm : np.ndarray
Band centre wavelengths [nm], shape (n_bands,).
band_widths_nm : np.ndarray
Full-width at half-maximum of each band [nm], shape (n_bands,).
band_names : tuple of str
Short names for each band (e.g. ('B11', 'B12')). Length matches
band_centers_nm.
srf_type : str
Shape of the synthetic SRF built by
:meth:InstrumentSpec.make_srf — one of 'gaussian',
'rectangular', 'triangular'.
Examples¶
Sentinel-2 B11/B12-like synthetic instrument::
InstrumentSpec(
name='S2A-like',
band_centers_nm=np.array([1610.0, 2190.0]),
band_widths_nm=np.array([90.0, 180.0]),
band_names=('B11', 'B12'),
)
make_srf
¶
Construct a :class:SpectralResponseFunction over wavelengths_nm.
Imported locally to avoid a circular import at module load.
Source code in src/plumax/radtran/config.py
number_density_cm3
¶
Ideal-gas total number density [molecules / cm³].
Source code in src/plumax/radtran/config.py
forward¶
Beer-Lambert forward models for atmospheric retrieval.
Three flavours, each returning radiance + Jacobian + transmittance:
- :func:
forward_nonlinear— exact Beer-LambertL = L₀ · exp(-τ). - :func:
forward_maclaurin— polynomial inVMRvia Maclaurin ofexp(-τ)aroundVMR = 0. Order 1 is the classical "linear" retrieval. - :func:
forward_taylor— Taylor-linearised around a background stateVMR_bg(the state used in 3D-/4D-Var inner loops).
Plus normalised variants that divide by the background radiance:
- :func:
forward_nonlinear_normalized—L_norm = exp(-Δτ); cancels surface reflectance, solar irradiance, and common aerosol slope. - :func:
forward_maclaurin_normalized/ :func:forward_taylor_normalized.
All functions take an xarray.Dataset LUT carrying an
absorption_cross_section variable with dims (wavenumber, temperature,
pressure) — the output of
:func:plumax.hapi_lut.build_lut_dataset.
Ported and adapted from
jej_vc_snippets/methane_retrieval/lut_model_beers.py — the Jacobian is
now always returned alongside the radiance so the same functions can drive
variational retrievals (H = dL/dVMR) and matched-filter target-spectrum
construction (t = H · ΔVMR).
ForwardResult
dataclass
¶
Bundle of (radiance, Jacobian, transmittance) returned by the forward models.
Using a frozen dataclass rather than a plain tuple gives the caller
named access — result.jacobian — which matters because several
functions in :mod:plumax.radtran.target and
:mod:plumax.radtran.matched_filter consume only the
Jacobian or only the transmittance.
Attributes¶
radiance : np.ndarray
Simulated radiance at each wavenumber, shape (n_nu,).
jacobian : np.ndarray
dL/dVMR evaluated at the supplied state, shape (n_nu,).
transmittance : np.ndarray
Atmospheric transmittance exp(-τ) (non-normalised variants) or
exp(-Δτ) (normalised variants), shape (n_nu,).
forward_nonlinear
¶
forward_nonlinear(ds: Dataset, nu_obs: ndarray, *, T_K: float, p_atm: float, vmr: float, path_length_cm: float, amf: float, surface_reflectance: float = 1.0, solar_irradiance: float = 1.0, var: str = 'absorption_cross_section') -> ForwardResult
Exact Beer-Lambert forward model.
Radiance: L(ν) = (F₀ R / π) · exp(-τ(ν, VMR))
Optical τ: τ = σ · N_total · VMR · L · AMF
Jacobian: dL/dVMR = -L · (dτ/dVMR), dτ/dVMR = σ · N_total · L · AMF
Defaults surface_reflectance = solar_irradiance = 1 collapse the
prefactor to 1/π, which is convenient for toy tests where the
absolute radiance scale does not matter.
Source code in src/plumax/radtran/forward.py
forward_nonlinear_normalized
¶
forward_nonlinear_normalized(ds: Dataset, nu_obs: ndarray, *, T_K: float, p_atm: float, vmr_background: float, delta_vmr: float, path_length_cm: float, amf: float, var: str = 'absorption_cross_section') -> ForwardResult
Exact Beer-Lambert normalised by the background radiance.
L_norm = L(VMR_bg + ΔVMR) / L(VMR_bg) = exp(-Δτ), which cancels any
multiplicative surface/solar/aerosol factors that appear equally in the
plume and background pixels.
Returns¶
ForwardResult
radiance is the normalised transmittance exp(-Δτ);
transmittance is the same quantity (kept for API symmetry with
:func:forward_nonlinear); jacobian is
d(L_norm)/d(ΔVMR) = -exp(-Δτ) · (σ · N · L · AMF).
Source code in src/plumax/radtran/forward.py
forward_maclaurin
¶
forward_maclaurin(ds: Dataset, nu_obs: ndarray, *, T_K: float, p_atm: float, vmr: float, path_length_cm: float, amf: float, surface_reflectance: float = 1.0, solar_irradiance: float = 1.0, order: int = 1, var: str = 'absorption_cross_section') -> ForwardResult
Maclaurin-series forward model: expand exp(-τ(VMR)) around VMR = 0.
With a = σ · N · L · AMF so τ = a · VMR:
- order 1:
T ≈ 1 − a·VMR(linear in VMR). - order 2:
T ≈ 1 − a·VMR + ½ (a·VMR)². - order 3:
T ≈ 1 − a·VMR + ½ (a·VMR)² − ⅙ (a·VMR)³.
Accurate when the total optical depth a·VMR is ≪ 1 — the regime of
classical linear retrievals for thin absorbers.
Source code in src/plumax/radtran/forward.py
forward_maclaurin_normalized
¶
forward_maclaurin_normalized(ds: Dataset, nu_obs: ndarray, *, T_K: float, p_atm: float, delta_vmr: float, path_length_cm: float, amf: float, order: int = 1, var: str = 'absorption_cross_section') -> ForwardResult
Maclaurin expansion of exp(-Δτ) around ΔVMR = 0.
At order 1 this reduces to L_norm ≈ 1 − σ · N · ΔVMR · L · AMF — the
canonical linearised retrieval signal.
Source code in src/plumax/radtran/forward.py
forward_taylor
¶
forward_taylor(ds: Dataset, nu_obs: ndarray, *, T_K: float, p_atm: float, vmr: float, vmr_background: float, path_length_cm: float, amf: float, surface_reflectance: float = 1.0, solar_irradiance: float = 1.0, var: str = 'absorption_cross_section') -> ForwardResult
First-order Taylor expansion around vmr_background.
L(VMR) ≈ L_b + H · (VMR − VMR_b) with H = dL/dVMR |_{VMR_b}. This
is the linearisation used in the inner loops of 3D-/4D-Var.
Source code in src/plumax/radtran/forward.py
forward_taylor_normalized
¶
forward_taylor_normalized(ds: Dataset, nu_obs: ndarray, *, T_K: float, p_atm: float, delta_vmr: float, path_length_cm: float, amf: float, var: str = 'absorption_cross_section') -> ForwardResult
Taylor expansion of L_norm around ΔVMR = 0.
Since L_norm(0) = 1 and dL_norm/dΔVMR(0) = -σ · N · L · AMF,
the first-order Taylor expansion coincides with the order-1 Maclaurin
expansion. Kept as a named function for API symmetry with
:func:forward_taylor.
Source code in src/plumax/radtran/forward.py
nb_lut¶
Normalised-brightness LUT for fast per-pixel plume injection.
The band-integrated normalised brightness for a pixel with methane column
enhancement ΔX is
nB_b(ΔX) = ∫ f_b(λ) · exp(-AMF · σ(λ) · ΔX) dλ / ∫ f_b(λ) dλ
where f_b is the instrument SRF for band b and σ is the target
gas cross-section in cm²/molecule. This integral is the natural scalar
"plume throughput" for band b once surface reflectance and solar
irradiance have been divided out (differential Beer-Lambert).
Evaluating the integral per pixel is expensive, so we follow the Eucalyptus
radtran() trick: pre-tabulate nB over a 1-D grid of ΔX values, then
look up per-pixel via piecewise-linear interpolation (np.interp). A
4-k-point LUT covering [0, 200] mol/m² costs ~1 ms to build and makes plume
injection a single np.interp call per band.
Public surface¶
- :func:
build_nb_lut— build the(n_bands, n_delta)LUT. - :func:
lookup_nb— look up nB per band for a 2-D plume map. - :func:
inject_plume— apply nB to a clean multispectral scene.
NBLookup
dataclass
¶
Pre-computed normalised-brightness table, indexed by band and ΔX.
Attributes¶
delta_column : np.ndarray
Column-enhancement grid [mol/m²], shape (n_delta,). Strictly
increasing, starts at 0.
nB : np.ndarray
Normalised brightness per band at each grid point, shape
(n_bands, n_delta). nB[:, 0] == 1 always.
band_names : tuple of str
Band labels, length n_bands.
to_dataset
¶
Wrap as an :class:xarray.Dataset for easy serialisation.
Source code in src/plumax/radtran/nb_lut.py
build_nb_lut
¶
build_nb_lut(ds: Dataset, srf: SpectralResponseFunction, *, T_K: float, p_atm: float, amf: float, max_delta_column: float = 200.0, n_grid: int = 4001, var: str = 'absorption_cross_section') -> NBLookup
Pre-tabulate band-integrated nB over a column-enhancement grid.
Parameters¶
ds : xr.Dataset
HAPI absorption-cross-section LUT, output of
:func:plumax.hapi_lut.build_lut_dataset.
srf : SpectralResponseFunction
Instrument SRF mapping the LUT wavelength grid to bands.
T_K, p_atm : float
Slab atmospheric temperature [K] and pressure [atm], at which the
cross-section σ(λ; T, P) is interpolated. ΔX is a column
already, so the slab path length does not enter the integrand.
amf : float
Two-way air-mass factor multiplying the slant column.
max_delta_column : float
Upper bound of the ΔX grid [mol/m²]. Default 200.
n_grid : int
Number of grid points (default 4001 gives 0.05 mol/m² resolution
over [0, 200] — more than enough for 1-ppm-level retrievals).
var : str
Cross-section variable name (default 'absorption_cross_section',
matching :func:plumax.hapi_lut.build_lut_dataset).
Returns¶
NBLookup
nB[b, k] is the band-integrated normalised brightness at
delta_column[k] for band b.
Source code in src/plumax/radtran/nb_lut.py
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 164 165 166 167 168 169 170 171 172 173 174 | |
lookup_nb
¶
Look up per-band nB for every pixel of a 2-D column map.
Values outside the LUT range are clipped. Clipping at the top end is physically conservative (saturates the absorption); negative inputs are clipped to zero since they are not physical.
Parameters¶
plume_column : np.ndarray
Column enhancement [mol/m²], shape (ny, nx) or any broadcastable
shape. NaNs are preserved as NaNs in the output.
nb_lookup : NBLookup
Pre-built LUT.
Returns¶
nb_map : np.ndarray
Shape (n_bands, *plume_column.shape). Dimensionless multiplier
in [0, 1] (1 at ΔX=0).
Source code in src/plumax/radtran/nb_lut.py
inject_plume
¶
inject_plume(scene: ndarray, plume_column: ndarray, nb_lookup: NBLookup, *, band_axis: int = 0) -> np.ndarray
Multiplicative plume injection: scene_plume = scene · nB(ΔX).
Parameters¶
scene : np.ndarray
Clean multispectral scene, shape (n_bands, ny, nx) by default
(band_axis=0).
plume_column : np.ndarray
ΔX column map [mol/m²], shape (ny, nx).
nb_lookup : NBLookup
Pre-built LUT. nb_lookup.n_bands must match scene.shape[band_axis].
band_axis : int
Axis of scene corresponding to bands. Default 0.
Returns¶
scene_plume : np.ndarray
Same shape as scene.
Source code in src/plumax/radtran/nb_lut.py
srf¶
Spectral Response Function — band integration of hyperspectral radiance.
The HAPI LUTs in :mod:plumax.hapi_lut tabulate σ(ν, T, P) on a
fine line-by-line wavenumber grid. Real instruments observe band-integrated
radiance
L_b = ∫ f_b(λ) · L(λ) dλ / ∫ f_b(λ) dλ
where f_b(λ) is the instrument's Spectral Response Function for band b.
This module builds the (n_bands, n_λ) SRF matrix once — each row is a
normalised response profile — and applies it as a plain matrix multiplication
against a high-resolution radiance spectrum (1-D) or radiance cube (3-D).
The class mirrors the API in the Orbio Project Eucalyptus tutorial
(SpectralFilter + get_sensor_spectral_response_function) but exposes
a linear-operator view: apply (forward), jacobian (tangent action —
identical to forward since SRF is linear), and adjoint (Sᵀ for
reverse-mode / VJP use in 3DVar). These three methods match the operator
interface used by the observation-operator classes in the original
jej_vc_snippets/methane_retrieval/lut_obs_op.py.
Three parametric SRF shapes are supported:
'gaussian':exp[-(λ-λ_c)² / (2 σ²)]withFWHM = 2.355 σ.'rectangular': 1 inside[λ_c − W/2, λ_c + W/2], 0 outside.'triangular': linear ramp peaking atλ_c, zero at the edges.
Plus 'custom' which accepts an arbitrary (n_bands, n_λ) matrix
(each row gets normalised to sum to 1).
SpectralResponseFunction
dataclass
¶
SpectralResponseFunction(wavelengths_hr_nm: ndarray, band_centers_nm: ndarray, band_widths_nm: ndarray, band_names: tuple[str, ...] = ('B0',), srf_type: SRFType = 'gaussian', custom_srfs: ndarray | None = None)
Band-integration operator for a multispectral / hyperspectral instrument.
Attributes¶
wavelengths_hr_nm : np.ndarray
High-resolution wavelength grid [nm], shape (n_lambda,). Must be
monotone increasing.
band_centers_nm : np.ndarray
Band centre wavelengths [nm], shape (n_bands,).
band_widths_nm : np.ndarray
Band FWHM (for 'gaussian'), full-width (for 'rectangular'/'triangular')
[nm], shape (n_bands,).
band_names : tuple of str
Band labels, length n_bands.
srf_type : str
'gaussian' | 'rectangular' | 'triangular' | 'custom'.
custom_srfs : np.ndarray or None
Used only when srf_type == 'custom'. Shape (n_bands, n_lambda).
Notes¶
Rows of :attr:matrix are L1-normalised so that a flat input spectrum
L(λ) = c maps to L_b = c for every band; this matches the
"dot-product-in-frequency" convention in the Eucalyptus tutorial.
apply
¶
Forward operator: map HR spectrum/cube to band-integrated values.
Parameters¶
radiance_hr : np.ndarray
Input radiance. Shape (n_lambda,) for a single spectrum, or
(..., n_lambda) for a cube (spatial + spectral).
Returns¶
radiance_bands : np.ndarray
Band-integrated radiance with the final axis replaced by
n_bands.
Source code in src/plumax/radtran/srf.py
jacobian
¶
adjoint
¶
Adjoint operator Sᵀ: map band-space cotangents back to HR.
Used for reverse-mode / VJP gradient calculations.
Parameters¶
cotangent_bands : np.ndarray
Band-space cotangent, shape (..., n_bands).
Returns¶
cotangent_hr : np.ndarray
HR-space cotangent, shape (..., n_lambda).
Source code in src/plumax/radtran/srf.py
check_srf_lut_coverage
¶
check_srf_lut_coverage(srf: SpectralResponseFunction, lut_wavelengths_nm: ndarray, *, context: str, tol: float = 0.01) -> np.ndarray
Warn when SRF bands respond outside the LUT's wavelength coverage.
Resampling a cross-section / target spectrum onto the SRF grid zero-fills
wavelengths beyond the LUT range (np.interp(..., left=0, right=0)),
silently attenuating any band whose response reaches there — a band whose
support lies wholly outside gets an exactly-zero (and misleading) value.
This computes each band's fraction of SRF mass outside the LUT coverage
and warns for bands exceeding tol.
Parameters¶
srf
Instrument SRF.
lut_wavelengths_nm
Wavelengths [nm] covered by the LUT (any order); coverage is taken as
[min, max].
context
Caller name, prepended to the warning message.
tol
Out-of-coverage SRF-mass fraction above which a band is flagged.
Returns¶
frac_out : np.ndarray Per-band fraction of SRF response outside the LUT coverage.
Source code in src/plumax/radtran/srf.py
instrument¶
JAX point-spread + ground-sampling instrument operators.
These two operators model the deterministic part of an imaging-spectrometer forward chain that sits between the radiative-transfer output and the band-integration step:
HR radiance cube ──PSF──► blurred HR cube ──GSD──► LR cube ──SRF──► bands
The :class:~plumax.radtran.srf.SpectralResponseFunction already
covers the SRF stage; this module fills in the PSF (spatial blur) and GSD
(detector down-sampling). Both operators here are:
- JAX-native — every method is
jit-able andvmap-able; gradients flow throughapply/jacobian/adjointvia reverse-mode AD without manual bookkeeping. This is the prerequisite for the variational retrieval module :mod:plumax.assimilation. - Linear —
jacobian == apply(state-independent), andadjointimplements the Euclidean transpose so the inner-product identity⟨A x, y⟩ = ⟨x, Aᵀ y⟩holds to machine precision. - Cube-shaped (band-last) — inputs are
(ny, nx, n_lambda); channels are processed independently. This matches the convention in :mod:plumax.radtran.srfand plays nicely withjax.vmapalong the spectral axis.
The numpy reference for the PSF/GSD logic lives in
jej_vc_snippets/methane_retrieval/lut_obs_op.py; this is the JAX rewrite
that lets the same operators participate in autodiff-based 3D-Var solves.
PointSpreadFunction
dataclass
¶
2D PSF blur applied independently per spectral channel.
The PSF is a fixed convolution kernel (kernel_size × kernel_size,
L1-normalised) so the operator is linear and shift-invariant. The
Jacobian is the operator itself; the adjoint is convolution with the
180°-flipped kernel (real-valued case).
The class is frozen=True, eq=False so the default
object.__eq__ (identity comparison) and object.__hash__
(id-based) are retained. An auto-generated dataclass __eq__
would do element-wise equality on the kernel: np.ndarray field
— ambiguous in bool context — and the corresponding __hash__
would crash because numpy arrays are unhashable. Identity semantics
are what jax.jit static-arg caching actually wants: a PSF
instance is re-used as-is or replaced wholesale; it never needs
structural equality.
Attributes¶
kernel : np.ndarray
L1-normalised 2-D kernel, shape (kernel_size, kernel_size).
Stored as numpy for transparent inspection; the JAX copies used by
:meth:apply / :meth:adjoint are cached as private fields below.
gaussian
classmethod
¶
Build an isotropic Gaussian PSF.
FWHM = 2.355 σ; the kernel is sampled on the discrete pixel grid
and L1-normalised in __post_init__.
Source code in src/plumax/radtran/instrument.py
airy
classmethod
¶
Build an Airy-disk-like PSF as a diffraction-ish surrogate.
fwhm_pixels is the kernel's full width at half maximum, matching
:meth:gaussian. The Airy argument is scaled so the half-max radius
(x = _AIRY_HALF_MAX_X) falls at r = fwhm_pixels / 2; the earlier
1.22·fwhm (first-zero) scaling produced a kernel ≈ 0.63× the
requested FWHM.
Source code in src/plumax/radtran/instrument.py
apply
¶
Forward blur: y = K * x per channel.
Source code in src/plumax/radtran/instrument.py
jacobian
¶
adjoint
¶
Adjoint: convolution with the flipped kernel.
For a real-valued L1-normalised kernel K the discrete adjoint of
the per-channel cross-correlation y = K ⋆ x is Kᵀ ⋆ u = K[::-1] ⋆ u.
Symmetric kernels (e.g. Gaussian) are self-adjoint.
Source code in src/plumax/radtran/instrument.py
GroundSamplingDistance
dataclass
¶
Block-mean down-sampling operator for a fixed integer factor.
Maps an HR cube (ny, nx, n_lambda) to LR
(ny // f, nx // f, n_lambda) by averaging non-overlapping f × f
spatial blocks. If (ny, nx) is not divisible by f, the input is
truncated to (ny // f * f, nx // f * f) (top-left crop).
The adjoint distributes each LR value uniformly across the corresponding
HR block with weight 1 / f² so that ⟨A x, y⟩ = ⟨x, Aᵀ y⟩ holds
exactly under the standard Euclidean inner product.
Attributes¶
downsample_factor : int
Integer block size f.
Notes¶
The classmethod :meth:from_optics derives f from the standard
pinhole-camera GSD formula
GSD [m/px] = sensor_width [mm] · altitude [m] · 1000 / (focal_length [mm] · image_width [px])
and rounds to the nearest integer. For non-integer ratios you should
pre-resample the HR cube — block-mean is only well-defined for integer
factors.
from_optics
classmethod
¶
from_optics(sensor_width_mm: float, focal_length_mm: float, image_width_px: int, altitude_m: float, pixel_size_hr_m: float) -> GroundSamplingDistance
Build a GSD operator from camera + altitude + HR pixel size.
Dimensional analysis:
GSD [m/px] = (sensor_pixel_size [m/px]) · (altitude [m]) / (focal_length [m])
= (sensor_width_mm / image_width_px) · altitude_m / focal_length_mm
The mm units on numerator and denominator cancel, so no unit-conversion
factor is needed — the numpy reference snippet we ported from had a spurious
× 1000 that inflated every realistic camera's GSD by a factor of 1000.
Source code in src/plumax/radtran/instrument.py
lr_shape
¶
LR shape implied by an HR shape and the current factor.
Source code in src/plumax/radtran/instrument.py
apply
¶
Forward down-sample by block mean.
Source code in src/plumax/radtran/instrument.py
jacobian
¶
adjoint
¶
Adjoint: spread each LR value uniformly across its HR block.
Parameters¶
cotangent : Array
LR cotangent, shape (ny_lr, nx_lr, n_lambda).
hr_shape : tuple
Target HR shape (ny_hr, nx_hr, n_lambda). Truncated entries
from :meth:apply (when ny_hr or nx_hr are not multiples
of f) are filled with zeros — the adjoint of the truncation
is the inclusion (... → ny_lr·f rows).
Source code in src/plumax/radtran/instrument.py
target¶
Target-spectrum construction for matched-filter retrievals.
The matched filter projects an observed pixel spectrum onto the expected plume signature, normalised by the background covariance. That signature — the "target spectrum" — is the radiance (or normalised radiance) perturbation a reference plume enhancement would produce given the current atmospheric state.
This module offers three flavours, all returning NumPy arrays shaped
(n_channels,) where n_channels is either the LUT wavenumber count
(hyperspectral) or the number of SRF bands (multispectral) — see
:func:target_bands for the band-integrated variant.
- :func:
target_spectrum_normalized_nonlinear— exactexp(-Δτ) - 1. Use whenΔτcan approach unity (strong-plume regime). - :func:
target_spectrum_normalized_linear— first-order Maclaurin,t ≈ -σ·N·ΔVMR·L·AMF. Classical linearised matched filter. - :func:
target_bands— integrate any hi-res target through an SRF to produce a band-space target.
All three are ported with simplifications from
jej_vc_snippets/methane_retrieval/matched_filter_beerslaw.py — dropping
the duplicate "reverse order" Taylor/Maclaurin variants since they agree
with the standard-order linearisation at first order.
target_spectrum_normalized_nonlinear
¶
target_spectrum_normalized_nonlinear(ds: Dataset, nu_obs: ndarray, *, T_K: float, p_atm: float, vmr_background: float, delta_vmr: float, path_length_cm: float, amf: float, var: str = 'absorption_cross_section') -> np.ndarray
Exact normalised-radiance target t(ν) = exp(-Δτ(ν)) − 1.
Negative for absorption. Magnitude tracks the cross-section profile and
scales with ΔVMR.
vmr_background is accepted for API symmetry with the linearised
form but is not used here: the normalised nonlinear model depends
only on ΔVMR. Keeping the kwarg means callers can swap forward
models without editing the call site.
Source code in src/plumax/radtran/target.py
target_spectrum_normalized_linear
¶
target_spectrum_normalized_linear(ds: Dataset, nu_obs: ndarray, *, T_K: float, p_atm: float, delta_vmr: float, path_length_cm: float, amf: float, var: str = 'absorption_cross_section') -> np.ndarray
First-order linearised target t(ν) = -σ · N · ΔVMR · L · AMF.
Equivalent to target_spectrum_normalized_nonlinear in the thin-plume
limit Δτ ≪ 1 and considerably faster — no exponential per pixel.
This is the canonical matched-filter target for weak retrievals.
Source code in src/plumax/radtran/target.py
target_bands
¶
target_bands(target_hr: ndarray, srf: SpectralResponseFunction, wavelengths_nm: ndarray) -> np.ndarray
Band-integrate a hi-res target spectrum through an SRF.
Parameters¶
target_hr : np.ndarray
Target spectrum on the LUT wavenumber grid, shape (n_nu,).
srf : SpectralResponseFunction
Instrument SRF defined on srf.wavelengths_hr_nm.
wavelengths_nm : np.ndarray
Wavelength grid 1e7 / nu_obs [nm] at which target_hr is
evaluated. Must be the same length as target_hr but may be in
decreasing order (we sort internally).
Returns¶
target_b : np.ndarray
Shape (n_bands,).
Source code in src/plumax/radtran/target.py
background¶
Robust background statistics for matched-filter retrievals.
The matched filter needs a background mean spectrum μ and an inverse
covariance Σ⁻¹ estimated from the clean pixels in the scene. Real
scenes have contaminants — plumes, clouds, shadows, bright targets — that
would bias a naive mean and inflate a naive covariance, so both estimators
here are written to be robust against outlier pixels.
- :func:
trimmed_mean_spectrum— per-channel trimmed mean. Cheap, robust to a few percent of outliers, matches a Gaussian mean in the clean limit. - :func:
robust_lowrank_covariance— low-rank plus diagonal-regularisation covariance via truncated SVD on the mean-subtracted, trimmed pixel stack. Returns(Σ, Σ⁻¹)ready for the matched filter.
Both operate on a pixel × band array (or an xarray.DataArray with
band as the last dim). The trim_frac parameter is a two-sided
fraction in [0, 0.5) — 0.1 removes the brightest and darkest 10%
of pixels per channel before averaging.
Heavily simplified from jej_vc_snippets/methane_retrieval/matched_filter_{mean,covariance}.py
(GMM-based background estimators and dynamic-mode rejection dropped — the
trimmed mean + truncated SVD covers the demo regime with far less tuning).
trimmed_mean_spectrum
¶
trimmed_mean_spectrum(radiance: ndarray, *, trim_frac: float = 0.1, band_axis: int = 0) -> np.ndarray
Per-channel trimmed mean μ_b.
Parameters¶
radiance : np.ndarray
Radiance cube, shape (n_bands, ny, nx) by default (band_axis=0),
or any array where one axis indexes the band.
trim_frac : float
Fraction trimmed from each tail per channel, in [0, 0.5).
Default 0.1 removes the top and bottom 10% per band.
band_axis : int
Band axis of radiance. Default 0.
Returns¶
mu : np.ndarray
Trimmed mean per band, shape (n_bands,).
Source code in src/plumax/radtran/background.py
robust_lowrank_covariance
¶
robust_lowrank_covariance(radiance: ndarray, *, rank: int | None = None, trim_frac: float = 0.1, regularization: float | None = None, band_axis: int = 0) -> tuple[np.ndarray, np.ndarray]
Robust low-rank covariance + inverse (Σ, Σ⁻¹).
The pixel stack is centred with a trimmed mean, the extreme 2·trim_frac
pixels (by total energy) are removed, and the covariance is estimated as
Σ = U_k · S_k² · U_kᵀ / N + λ · I
where U_k are the top-k left singular vectors of the centred
matrix. The added diagonal term λ · I stabilises the inverse when
the covariance is ill-conditioned. Returns both Σ and Σ⁻¹ since
the matched filter needs the inverse and diagnostics sometimes want the
forward covariance too.
Parameters¶
radiance : np.ndarray
Shape (n_bands, ny, nx) by default.
rank : int or None
Truncation rank. None uses min(n_bands - 1, 16) — the
practical sweet spot for multispectral data where most of the
variance is in a handful of modes.
trim_frac : float
Per-pixel energy-based trim fraction in [0, 0.5). Default 0.1.
regularization : float or None
Diagonal Tikhonov floor λ on the discarded subspace. None
(default) uses the noise-matched floor — the mean of the discarded
covariance eigenvalues mean(S[rank:]²)/N — so Σ⁻¹ weights that
subspace at the real sensor-noise level instead of an arbitrary
constant. A floor too small over-weights the noise subspace (inflating
false alarms); too large leaks signal into it. Pass a float to override.
band_axis : int
Band axis of radiance. Default 0.
Returns¶
Sigma, Sigma_inv : np.ndarray
Symmetric PSD matrices, shape (n_bands, n_bands).
Source code in src/plumax/radtran/background.py
matched_filter¶
Matched-filter retrieval for multispectral / hyperspectral methane detection.
The matched filter is the optimal linear detector for a known target
signature embedded in additive Gaussian noise with known covariance. For a
pixel spectrum x ∈ ℝ^B, background mean μ, covariance Σ, and
target t, the maximum-likelihood abundance estimate is
ε̂ = (x − μ)ᵀ Σ⁻¹ t / (tᵀ Σ⁻¹ t) .
Positive values of ε̂ indicate the target is present; ε̂ ≈ ΔVMR /
ΔVMR_ref when t is the target spectrum produced by
:func:plumax.radtran.target.target_spectrum_normalized_linear
at delta_vmr = ΔVMR_ref. Multiplying ε̂ back by ΔVMR_ref gives
an estimate of the actual pixel VMR enhancement.
This module exposes the raw operation at three scopes:
- :func:
matched_filter_pixel— one pixel (scalar output). - :func:
matched_filter_image— vectorised over spatial axes. - :func:
matched_filter_snr— per-pixel detection SNR.
Ported from jej_vc_snippets/methane_retrieval/matched_filter.py with the
dask/chunked path dropped (callers who need it can chunk the input themselves
and map :func:matched_filter_image over chunks).
matched_filter_pixel
¶
matched_filter_pixel(spectrum: ndarray, mean_spectrum: ndarray, cov_inv: ndarray, target: ndarray) -> float
Matched-filter abundance for a single pixel.
Parameters¶
spectrum : np.ndarray
Pixel spectrum, shape (n_bands,).
mean_spectrum : np.ndarray
Background mean, shape (n_bands,).
cov_inv : np.ndarray
Inverse background covariance Σ⁻¹, shape (n_bands, n_bands).
target : np.ndarray
Target signature, shape (n_bands,).
Returns¶
abundance : float
Scalar retrieval in the same units as target.
Source code in src/plumax/radtran/matched_filter.py
matched_filter_image
¶
matched_filter_image(radiance: ndarray, mean_spectrum: ndarray, cov_inv: ndarray, target: ndarray, *, band_axis: int = 0) -> np.ndarray
Matched-filter abundance map, vectorised over spatial axes.
Parameters¶
radiance : np.ndarray
Radiance cube, shape (n_bands, ny, nx) by default.
mean_spectrum, cov_inv, target : np.ndarray
As in :func:matched_filter_pixel.
band_axis : int
Band axis of radiance. Default 0.
Returns¶
abundance : np.ndarray
Shape radiance.shape minus the band axis — e.g. (ny, nx) for
the default layout.
Source code in src/plumax/radtran/matched_filter.py
matched_filter_snr
¶
Detection SNR for a matched-filter abundance map.
For the matched filter, SNR(x) = (target energy) · ε̂ = ε̂ · √(tᵀ Σ⁻¹ t).
Parameters¶
abundance : np.ndarray
Output of :func:matched_filter_image, shape (..., ny, nx).
cov_inv : np.ndarray
Inverse covariance used to build the filter.
target : np.ndarray
Target used to build the filter.
Returns¶
snr : np.ndarray
Same shape as abundance.
Source code in src/plumax/radtran/matched_filter.py
gaussx_solve¶
Gaussx-backed structured solves for the matched filter.
The default matched_filter_* functions in :mod:.matched_filter take a
materialised inverse covariance Σ⁻¹ ∈ ℝ^{B×B} and compute
ε̂ = (x - μ)ᵀ Σ⁻¹ t / (tᵀ Σ⁻¹ t)
directly. That's fine when B is small (multispectral) but wasteful for
hyperspectral retrievals with B ≳ 200 because:
- The
O(B³)dense inverse is entirely avoidable — the empirical covariance is naturally represented as a low-rank updateΣ = λ I + U diag(d) Uᵀ. - Every solve can then run in
O(B·k)via the Woodbury identity, wherekis the retained rank (typically 8–32).
This module wraps :mod:gaussx's :class:gaussx.LowRankUpdate operator and
structural :func:gaussx.solve so the matched filter can stay
inversion-free on the hot path:
- :func:
build_lowrank_covariance_operator— construct a :class:gaussx.LowRankUpdatefrom the same SVD the dense estimator uses. - :func:
matched_filter_pixel_op/ :func:matched_filter_image_op— operator-backed matched filter that callsgaussx.solve(cov, target)once and then dots the result into every pixel's innovation.
The functions accept NumPy inputs and return NumPy outputs — the gaussx internals run on JAX arrays for us, but callers don't need to know.
.. important::
This path requires 64-bit JAX (jax_enable_x64). With x64 disabled
(JAX's default) the float64 SVD factors are silently truncated to float32
and the ill-conditioned Woodbury capacitance solve loses precision (up to
~1e-5 relative, worse for hyperspectral B ≳ 200). Rather than return a
quietly-wrong result, every entry point raises a clear error when x64 is off
— enable it once at import time:
import jax; jax.config.update("jax_enable_x64", True).
build_lowrank_covariance_operator
¶
build_lowrank_covariance_operator(radiance: ndarray, *, rank: int | None = None, trim_frac: float = 0.1, regularization: float | None = None, band_axis: int = 0) -> tuple[LowRankUpdateOp, np.ndarray]
Build a :class:gaussx.LowRankUpdate covariance operator from a scene.
Parameters¶
radiance : np.ndarray
Radiance cube, shape (n_bands, ny, nx) by default.
rank, trim_frac, regularization, band_axis
Same semantics as :func:.background.robust_lowrank_covariance —
see that function for the trimming and rank-selection details.
Returns¶
cov : gaussx.LowRankUpdate
Structured operator λ I + U diag(d) Uᵀ (symmetric + PSD tagged)
where d = S_k² / N are the squared singular values of the
trimmed, mean-subtracted pixel stack.
mu : np.ndarray
Background mean spectrum, shape (n_bands,). Returned here
(rather than a separate call) so callers build μ and Σ from the
same trimmed pixel stack — avoiding a subtle bias where μ drops
outliers that Σ's trim step kept.
Source code in src/plumax/radtran/gaussx_solve.py
matched_filter_pixel_op
¶
matched_filter_pixel_op(spectrum: ndarray, mean_spectrum: ndarray, cov: LowRankUpdateOp, target: ndarray) -> float
Scalar matched filter using a structured covariance operator.
Computes Σ⁻¹ t via :func:gaussx.solve once (O(B·k) via Woodbury),
then projects a single pixel's innovation onto it.
Source code in src/plumax/radtran/gaussx_solve.py
matched_filter_image_op
¶
matched_filter_image_op(radiance: ndarray, mean_spectrum: ndarray, cov: LowRankUpdateOp, target: ndarray, *, band_axis: int = 0) -> np.ndarray
Vectorised matched filter using a structured covariance operator.
Parameters¶
radiance : np.ndarray
Radiance cube, shape (n_bands, ny, nx) by default.
mean_spectrum : np.ndarray
Background mean, shape (n_bands,).
cov : gaussx.LowRankUpdate
Structured covariance operator (from
:func:build_lowrank_covariance_operator).
target : np.ndarray
Target signature, shape (n_bands,).
band_axis : int
Band axis of radiance. Default 0.
Returns¶
abundance : np.ndarray
Pixel-wise retrieval, shape radiance.shape minus the band axis.
Source code in src/plumax/radtran/gaussx_solve.py
matched_filter_snr_op
¶
Detection SNR when the covariance is a gaussx operator.
Mirrors :func:plumax.radtran.matched_filter.matched_filter_snr
but uses :func:gaussx.solve to avoid materialising Σ⁻¹.