Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

radtrax: design and roadmap

radtrax · 3 · A differentiable radiative transfer package in JAX and Equinox, formerly rtmx — what to build, in what order, and what the stack already has

Abstract

radtrax is the radiative transfer package that the stack does not have yet. It is a forward model y=h(x)\vect{y} = h(\vect{x}) from an atmospheric state x∈RDx\vect{x} \in \mathbb{R}^{D_x} to what a sensor measures, y∈RDy\vect{y} \in \mathbb{R}^{D_y}, and we write it in JAX and Equinox so that every Jacobian a retrieval needs comes from automatic differentiation rather than from hand-derived code. It was called rtmx in the earlier design, and the design itself is unchanged in intent. In this page, we set out 1) the architecture (a typed pipeline from atmosphere to instrument, with the per-layer optical properties as the canonical differentiable interface), 2) a staged roadmap from Beer–Lambert to multiple scattering, and 3) the package’s place in the stack, and we check each against code that exists. Three findings shape it:

  • v0 is a port, not a start. plumax already has most of v0 on one slab. A layered Equinox v0 reproduces its radiance and its hand-written Jacobian to 10-15, and one slab at the surface’s pressure misstates a band’s optical depth by up to 19 %.

  • The source note’s Equinox sketch needs three fixes: no JAX arrays in static fields (Equinox warns), no AbstractVar on a concrete module, and optimistix and optax in place of jaxopt and optax-contrib.

  • The stack has half of the v4 linear algebra. gaussx’s block-tridiagonal operator solves the structure of the boundary-value problem to 10-16, but only for a symmetric band, whereas discrete ordinates needs a general one.

Keywords:radtraxrtmxradiative transferEquinoxJAXdesign

What radtrax is for

TL;DR. One forward model, every derivative of it, and four retrievals that need them.

Problem. A retrieval inverts y=h(x)+ε\vect{y} = h(\vect{x}) + \gvect{\varepsilon} for the state x∈RDx\vect{x} \in \mathbb{R}^{D_x} (trace-gas profiles, temperature, surface, instrument), where y∈RDy\vect{y} \in \mathbb{R}^{D_y} is the measurement and ε∈RDy\gvect{\varepsilon} \in \mathbb{R}^{D_y} is its noise. Every serious inversion needs the Jacobian Jh=∂h/∂x∈RDy×Dx\mat{J}_h = \partial h / \partial \vect{x} \in \mathbb{R}^{D_y \times D_x}: optimal estimation iterates on it Rodgers, 2000, a matched filter’s target spectrum is one column of it, and an emission-rate fit chains it with a plume model. The heritage codes either ship Jh\mat{J}_h by hand-derived linearisation (VLIDORT Spurr, 2006) or not at all (MODTRAN tables). radtrax’s one bet is to make {τl,ωl,χl,k,surface}\{\tau_l, \omega_l, \chi_{l,k}, \text{surface}\} the interface between spectroscopy and solvers (the optical depth, single-scattering albedo and phase-function moments kk of layer ll, all scalars, and the surface kernels), and to get Jh\mat{J}_h through it with jax.jacrev. Its uses, in the order the roadmap reaches them, are:

  1. Matched-filter targets for EMIT, PRISMA, EnMAP and AVIRIS-NG: the unit absorption spectrum ∂ln⁡y/∂ΔVMR∈RDy\partial \ln \vect{y} / \partial \Delta\mathrm{VMR} \in \mathbb{R}^{D_y} for a scene’s geometry and water vapour, in place of a MODTRAN table. It is the target Jh\vect{J}_h (the one column of Jh\mat{J}_h for the methane enhancement) of the matched filter in radtrax · 1 and Report 16. [CHECK: radtrax · 1 defines Jh\vect{J}_h as the derivative of the reflectance with respect to ΔΩ\Delta\Omega, which is this log derivative times μy\gvect{\mu}_y and a conversion from ΔVMR\Delta\mathrm{VMR} to ΔΩ\Delta\Omega, not the unit absorption spectrum itself.]

  2. Optimal-estimation retrievals of XCH4\mathrm{XCH_4}, XH2O\mathrm{XH_2O}, surface and spectral shift, with Jh\mat{J}_h from autodiff.

  3. Multispectral retrievals (Sentinel-2, Landsat): the band tables and the multi-pass family of radtrax · 2.

  4. Emission rates by maximum likelihood: plumax maps an emission rate QQ to a column, radtrax maps the column to radiance, and one gradient gives ∂L/∂Q\partial \mathcal{L} / \partial Q of the loss L\mathcal{L}. The IME and the cross-sectional flux of plumax · 1 become its starting point.

What exists today

TL;DR. Most of v0 is in plumax already, in NumPy, on one slab.

The package boundaries inventory lists every module and its size, and the table below keeps what radtrax changes in each.

PieceTodayWhat radtrax changes
HITRAN cross-section tables σ(ν;p,T)\sigma(\nu; p, T)plumax hapi_lut: HAPI, offline, on a (ν, T, p) gridkept as the offline table builder behind radtrax[hitran]
Beer–Lambert forward modelplumax radtran.forward: NumPy, one slab at one (p, T), Jacobian by handEquinox, layered, Jacobian by autodiff
Band integration, band tablesplumax radtran.srf, radtran.nb_lut; georeader read_srf: nB without the background column (radtrax · 2)one JAX Instrument; tables with the background
PSF, ground samplingplumax radtran.instrument: NumPy, hand-written adjointsJAX, adjoint by autodiff, the hand adjoints kept as tests
Matched filtersthree implementationsone JAX core
Observation operator, 3D-Varplumax assimilation: JAX, frozen dataclass, linear in ΔVMR\Delta\mathrm{VMR}, one slabthe v1 retrieval drivers, on the nonlinear layered model
Structured linear algebragaussx (LowRankUpdate, BlockTriDiag, solve)used as is

We have the pieces radtrax needs. What is missing is the layer dimension, a single Equinox forward model which they share, and everything past Beer–Lambert.

The architecture

TL;DR. Five stages, typed pytrees between them, and protocols where the alternatives plug in.

AtmosphericState      p, T, z per layer; VMR per gas         (N_L,), dict
      │  OpacityProvider: σ(ν; p_l, T_l) per gas, Σ n VMR σ Δz
      ▼
OpticalProperties     τ, ω (N_L, N_ν), χ (N_L, N_ν, N_χ)     ← J_h flows here
      │  + Surface: Lambertian | RossLi | RPV | CoxMunk
      │  RTSolver: BeerLambert | SingleScatter | TwoStream
      │            | DiscreteOrdinates(N) | DoublingAdding
      ▼
Radiance              L(ν) at the top of the atmosphere       (N_ν,)
      │  Instrument: SRF, spectral shift, FWHM, PSF, GSD
      ▼
Observation           y_b per band; NoiseModel → Σ_ε          (N_B,)
      │  Retrieval: OE (optimistix LM / GN) | MAP (optax)
      │             | Bayesian (numpyro) | matched filter
      ▼
State estimate        x̂, Σ_x|y, averaging kernel

We take four rules from the note and from the checks below:

  1. Modules carry state; functions carry transforms. A table, a line list, an SRF or a noise covariance is an eqx.Module, whereas a Voigt profile, a Chapman factor or a delta-M scaling is a function.

  2. Arrays are leaves. A JAX array in a static field makes Equinox warn and breaks hashing under filter_jit. So grids, tables and SRFs stay leaves, and we choose what is not differentiated with eqx.partition or jax.lax.stop_gradient.

  3. Protocols at the seams. OpacityProvider, RTSolver, Surface, Instrument and NoiseModel are abstract Equinox modules (eqx.AbstractVar and eqx.abstractmethod on the base, concrete fields on the subclass), so filter_jit compiles each combination.

  4. vmap outside jacrev. A swath is a filter_vmap over pixels of a per-pixel Jacobian, and never a Jacobian of a batch.

The layout we propose:

radtrax/
├── atmosphere/   AtmosphericState, AFGL profiles, hydrostatics
├── spectroscopy/ tables (v0), line-by-line (v2): partition functions,
│                 line strengths, Voigt, continua, CIA
├── optics/       OpticalProperties, Rayleigh, HG and Mie, delta-M
├── surface/      Lambertian, kernel BRDFs, Cox–Munk
├── solvers/      BeerLambert, SingleScatter, TwoStream, ...
├── instrument/   SRF, shift and FWHM, PSF, GSD, noise
├── retrieval/    optimal estimation, matched filter, multi-pass
└── geo/          GeoTensor wrappers, sensor SRF tables  [geo] extra

The v0 slice, run

TL;DR. A layered Equinox forward model reproduces plumax’s slab exactly, and shows what the slab gets wrong.

Problem. The smallest radtrax that is worth having is the note’s v0: an (ln p, T) table lookup per layer, Beer–Lambert along the two-way path, and a band integration with a spectral shift. We write it in the notebook with every array as a leaf:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Shapes: N_L = 20 layers, N_ν = 14,000 wavenumbers, N_B = 7 bands (8.5 nm)
# σ_l = bilinear(σ; ln p_l, T_l);  τ_l = σ_l n_l VMR_l Δz_l     (N_L, N_ν)
tau = TableOpacity(log_p, t_grid, sigma)(atm)    # proposed (radtrax)
# L = (μ₀/π) A exp(−AMF Σ_l τ_l),  AMF = 1/μ₀ + 1/μ             (N_ν,)
rad = BeerLambert()(tau, albedo, mu0, mu)        # proposed (radtrax)
# y_b = Σ_g f_bg L(ν_g + δ)                                     (N_B,)
y = Instrument(srf, nu, shift)(rad)              # proposed (radtrax)
# one slab against plumax: L and ∂L/∂x                          (N_ν,)
ref = forward_nonlinear(ds, nu, T_K=T0, p_atm=P0, vmr=X0, ...)  # today
# → radiance to 1.3 × 10⁻¹⁵, Jacobian to 6 × 10⁻¹⁶, relative  user
# J_h = ∂y/∂(ΔVMR₀, A, δ) per pixel, 256 pixels    (256, 3) → (256, 7, 3)
j_h = eqx.filter_vmap(jax.jacrev(y_of))(thetas)  # user
# → finite differences to 2.3 × 10⁻⁸; vmap equals a loop exactly

The corrected v0 forward model and its Jacobian, from a real run.

The layered model reduces to plumax’s slab to rounding, for the radiance and the Jacobian alike, and the Jacobian that plumax derives by hand comes out of jax.jacrev unchanged. However, the layers are not decoration. The same 1.9 ppm column (4.1×10194.1 \times 10^{19} molecules/cm²) through 20 one-kilometre layers and through one slab at the surface’s pressure differ by up to 19 % in a band’s optical depth, because the slab broadens every line as if all the methane sat at 1 atm, whereas a slab at the column’s pressure-weighted mean differs by up to 3 %. plumax’s forward model and its observation operator are both single-slab, which is the first thing v0 changes.

The roadmap

TL;DR. Six releases, each a working retrieval, each adding one fidelity axis, never a refactor and a feature together.

Table 1:The releases, what each ports and adds, and the check that closes it. Durations are the note’s estimates.

ReleasePorts from the stackAddsDeliversClosed when
v0 Beer–Lambert, 4–6 weeksplumax hapi_lut (tables), radtran.forward, srf, nb_lut, targetlayers; one Equinox forward model; band tables with the background; tables for CH₄, H₂O, CO₂, N₂O, COmatched-filter targets Jh(SZA,VZA,z,H2O)\vect{J}_h(\mathrm{SZA}, \mathrm{VZA}, z, \mathrm{H_2O}) with their gradientsslab equals plumax to 10-12 (done here); transmittance within 0.1 % of HAPI on AFGL US Standard; Jh\mat{J}_h against finite differences
v1 geometry, surface, instrument, noise, 4–6 weeksplumax assimilation, radtran.instrument; geotoolz plume.SBMP (an MBMP) and the MBSP of radtrax · 1Chapman air mass above 70°; kernel BRDFs; SRF shift and FWHM; noise covariance; the multi-pass family of radtrax · 2Rodgers OE for XCH4\mathrm{XCH_4}, XH2O\mathrm{XH_2O}, surface, shiftEMIT plume scenes against the operational product
v2 line-by-line, 8–12 weeksnoneVoigt (direct), DIT/FFT and precomputed line-basis opacity; partition functions; continua (MT_CKD), CIA, line mixingtables built in JAX, differentiable in the line parameters0.1 % against HAPI and LBLRTM on three AFGL atmospheres; single lines to 10-6
v3 single scattering, 8–10 weeksnoneRayleigh with King factor; HG and double HG; Mie on a precomputed grid; analytic single scatter with a TMS correctionaerosol-aware SWIR forward modelCoulson tables, miepython, 6SV
v4 multiple scattering, 12–16 weeksgaussx operatorstwo-stream and Toon adding; doubling–adding; discrete ordinates with implicit differentiationTROPOMI-class SWIR + O₂ A-band forward modelDISORT and VLIDORT to 0.5 % in radiance and 2 % in Jh\mat{J}_h
v5+pyroxpolarisation; Cox–Munk; 3-D; emulators behind the RTSolver protocoloperational throughputI3RC; an emulator’s error budget

Each release in Table 1 ships the regression set of the one before, and the gradient check (autodiff against central finite differences on a reduced state) is the one test every release runs.

Differentiability, component by component

TL;DR. Most of radtrax differentiates as written; four places do not, and the stack has some of the fixes.

Problem. Reverse mode through jnp code is exact where the code is smooth. The places where it is not are known from the heritage codes, and the notebook reproduces each one:

1
2
3
4
5
6
7
8
9
10
11
# (a) float32 on a wavenumber grid, near 4300 cm⁻¹
np.spacing(np.float32(4300.0))   # → 4.9e-4 cm⁻¹ (Doppler HWHM 6.6e-3)
# (b) M(μ) = (e^{−τ/μ₀} − e^{−τ/μ}) / (1/μ − 1/μ₀) at μ = μ₀  () → ()
multiplier_naive(mu0)            # → nan, and its gradient nan   user
multiplier_safe(mu0)             # Taylor branch, double-where   user
# → τ e^{−τ/μ₀} exactly; slope = finite differences to 10⁻⁴
# (c) ∂/∂A of eigenvectors, repeated eigenvalue        (3, 3) → (3, 3)
jax.grad(first_eigvec_sum)(diag(1, 1, 2))      # → not finite    user
jax.grad(first_eigvec_sum)(diag(1, 1.001, 2))  # → 500, as 1/gap user
# (d) block-tridiagonal solve, 30 blocks of 8          (240,) → (240,)
gx.solve(gx.BlockTriDiag(D, A), b)   # → dense solve to 5e-16   today

The four places autodiff needs help, from a real run.

  1. Precision. Line-by-line opacity runs in float64. In float32 the wavenumber grid itself is coarse, 4.9×10−44.9 \times 10^{-4} cm⁻¹ at 4300 cm⁻¹ (7 % of the Doppler half-width), so line centres move by a visible fraction of a line.

  2. Small denominators. The discrete-ordinates streaming multipliers are 0/0 at μ=μ0\mu = \mu_0, in value and in gradient. VLIDORT switches to a Taylor series once ∣μ−μ0∣\vert \mu - \mu_0 \vert is within about 10-3 Spurr, 2006. In JAX that branch needs the double-where (a safe denominator in the branch not taken), or else jnp.where still propagates the NaN into the gradient.

  3. Repeated eigenvalues. The gradients of eigenvectors from jnp.linalg.eigh scale as one over the eigenvalue gap, and they are not finite at a repeated eigenvalue. So the reduced eigenproblem of discrete ordinates Stamnes et al., 1988 needs a custom_jvp that differentiates the invariant subspace and not the individual vectors.

  4. The boundary-value solve. Discrete ordinates ends in one block-tridiagonal system per Fourier component, and implicit differentiation through it costs one more solve. gaussx’s BlockTriDiag solves that structure in O(Nd3)O(N d^3) (for NN blocks of size dd) and matches a dense solve to 5×10−165 \times 10^{-16}. However, its band is symmetric (Ak\mat{A}_k below the diagonal, Ak⊤\mat{A}_k^\top above), and with symmetric=False it falls back to a dense LU. The boundary-value matrix is not symmetric, so v4 needs a general block-tridiagonal solve, which belongs in gaussx.

For the fixed points (successive orders, SHDOM-like iterations) and the Gauss–Newton and Levenberg–Marquardt loops of optimal estimation we use optimistix (FixedPointIteration, GaussNewton, LevenbergMarquardt, with ImplicitAdjoint), which plumax’s assimilation already depends on, and L-BFGS for MAP retrievals is optax.lbfgs.

Dependencies and boundaries

TL;DR. radtrax depends on GeoNumerics and nothing above it; geo carriers arrive through an extra; plumax imports radtrax.

Depends onWhyHow
jax, equinox, jaxtypingthe forward model and its pytreescore
lineax, optimistixlinear solves, fixed points, OE drivers, implicit adjointscore
gaussxstructured covariances (matched filter, OE), block-tridiagonal solvescore
hitran-api (HAPI), radisoffline line lists and tablesradtrax[hitran]
georeader, geotoolzGeoTensor wrappers, sensor SRF tablesradtrax[geo]
numpyro, optaxBayesian and MAP retrievalsradtrax[bayes]
pyroxGP emulators behind the RTSolver protocolradtrax[emulate]

What to inherit from the landscape

The note surveys the field, and the table keeps what each code contributes to radtrax’s design. We checked the entries marked † for this page (the class names against ExoJAX’s documentation, the status against JAXopt’s README, the papers against their publication records), and the rest are the note’s.

CodeContributesWhere in radtrax
ExoJAX† Kawahara & others, 2022the closest JAX design: OpaDirect, OpaModit, OpaPremodit opacities; ArtEmisPure, ArtEmisScat, ArtTransPure, ArtReflectPure solvers; layer-wise scan for memoryv2 spectroscopy; v4 two-stream; plain JAX, which radtrax re-frames in Equinox
VLIDORT Spurr, 2006analytic K matrices in τ\tau, ω\omega, phase moments and surface; Taylor branches for small denominatorsthe K-matrix interface; v4 tests
DISORT Stamnes et al., 1988the reduced eigenproblem; exponential scaling; delta-M and TMSv4 discrete ordinates
ARTSworkspace and agendas: typed state, rewireable pipelinesthe module DAG
libRadtranone optical setup, many solversthe RTSolver protocol
py4CAtStyped stages: cross section → absorption → optical depth → radiancethe pytree chain
RTTOVforward, tangent linear, adjoint and K as one modeljvp, vjp, jacrev of one function
Korkin et al.† Korkin et al., 2022 (gsit)make it right, then fast; Fourier azimuth modes as a vmap axis; analytic single scatter subtracted and added backv3 and v4 structure
PyMieDiff† (arXiv 2512.08614)autograd-safe Bessel recurrences, in PyTorchv3 Mie
sRTMnet, ISOFITemulated radiative transfer in operationsv5 emulators

Open questions

  1. Where does the 3D-Var retrieval live? plumax’s assimilation is radtrax’s v1 driver in all but name. The package boundaries page asks whether it belongs in radtrax or in vardax, and v1 needs the answer.

  2. Tables or line-by-line as the default? v0’s tables are fast and differentiable in the state, whereas v2’s line-by-line is also differentiable in the line parameters, which is what HITRAN-uncertainty propagation needs. Whether retrievals default to v2 once it exists is a cost question.

  3. How general must the block-tridiagonal solve be? A non-symmetric BlockTriDiag in gaussx serves discrete ordinates and any other two-point boundary-value problem, and it is the one GeoNumerics addition that v4 needs.

  4. Which sensor first for v1? EMIT has operational products to compare against, whereas Sentinel-2 has the multi-pass family already written down.

Upcoming work

Table 2 lists the steps, in order.

Table 2:What radtrax needs, in order.

StepWhereWhy
Create the radtrax repository from the package template, with the layout abovenew repothe package does not exist
Port hapi_lut, radtran and assimilation’s observation operator into one layered Equinox forward modelradtrax v0, from plumaxv0 slice: plumax’s slab, its hand Jacobian and a 19 % slab error
Build band tables against the background columnradtrax v0radtrax · 2: 1.1 % on band 12
Add a non-symmetric BlockTriDiaggaussxdifferentiability, item 4: discrete ordinates’ boundary-value matrix is not symmetric
Add the gradient check (autodiff against finite differences) to every public forward function’s testsradtraxthe one test every release runs
Update the package boundaries graph with radtrax’s extrasgeostack docs[hitran], [bayes] and [emulate] are new edges

References

References
  1. Johnson, J. E. (2026). A differentiable JAX/Equinox RTM for methane and multi-gas remote sensing: design and staged roadmap. Unpublished design note (as rtmx).
  2. Korkin, S., Sayer, A. M., Ibrahim, A., & Lyapustin, A. (2022). A practical guide to writing a radiative transfer code. Computer Physics Communications, 271, 108198.
  3. Kawahara, H., & others. (2022). Autodifferentiable spectrum model for high-dispersion characterization of exoplanets and brown dwarfs. The Astrophysical Journal Supplement Series, 258, 31.
  4. Spurr, R. (2006). VLIDORT: a linearized pseudo-spherical vector discrete ordinate radiative transfer code for forward model and retrieval studies in multilayer multiple scattering media. Journal of Quantitative Spectroscopy and Radiative Transfer, 102, 316–342.
  5. Stamnes, K., Tsay, S.-C., Wiscombe, W., & Jayaweera, K. (1988). Numerically stable algorithm for discrete-ordinate-method radiative transfer in multiple scattering and emitting layered media. Applied Optics, 27(12), 2502–2509.
  6. Rodgers, C. D. (2000). Inverse Methods for Atmospheric Sounding: Theory and Practice. World Scientific.