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 the stack does not have yet: a forward model y=F(x)y = F(x) from an atmospheric state to what a sensor measures, written in JAX and Equinox so that every Jacobian a retrieval needs comes from automatic differentiation rather than hand-derived code. It was called rtmx in the earlier design; the design itself is unchanged in intent. This page sets out the architecture (a typed pipeline from atmosphere to instrument, with the per-layer optical properties as the canonical differentiable interface), a staged roadmap from Beer–Lambert to multiple scattering, and the package’s place in the stack, and checks 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 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 boundary-value problem’s structure to 10-16, but only for a symmetric band; 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=F(x)+εy = F(x) + \varepsilon for the state xx (trace-gas profiles, temperature, surface, instrument), and every serious inversion needs K=∂F/∂xK = \partial F / \partial 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 KK 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 per-layer optical depth, single-scattering albedo, phase-function moments and surface kernels, the interface between spectroscopy and solvers, and to get KK through it by jax.jacrev. Its uses, in the order the roadmap reaches them:

  1. Matched-filter targets for EMIT, PRISMA, EnMAP and AVIRIS-NG: the unit absorption spectrum t=∂ln⁡y/∂ΔXt = \partial \ln y / \partial \Delta X for a scene’s geometry and water vapour, replacing a MODTRAN table; it is the tt of the matched filter in radtrax · 1 and Report 16.

  2. Optimal-estimation retrievals of XCH4X_{\text{CH}_4}, XH2OX_{\text{H}_2\text{O}}, surface and spectral shift, with KK 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 ee to a column, radtrax maps the column to radiance, and one gradient gives ∂loss/∂e\partial \text{loss} / \partial e; 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; the table 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 ΔX\Delta X, one slabthe v1 retrieval drivers, on the nonlinear layered model
Structured linear algebragaussx (LowRankUpdate, BlockTriDiag, solve)used as is

The pieces radtrax needs are present; what is missing is the layer dimension, a single Equinox forward model 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         (L,), dict
      │  OpacityProvider: σ(ν; p_l, T_l) per gas, Σ n x σ Δz
      ▼
OpticalProperties     τ (L, G), ω (L, G), χ (L, G, K)        ← K flows here
      │  + Surface: Lambertian | RossLi | RPV | CoxMunk
      │  RTSolver: BeerLambert | SingleScatter | TwoStream
      │            | DiscreteOrdinates(N) | DoublingAdding
      ▼
Radiance              L(ν) at the top of the atmosphere       (G,)
      │  Instrument: SRF, spectral shift, FWHM, PSF, GSD
      ▼
Observation           y_b per band; NoiseModel → Σ_y          (B,)
      │  Retrieval: OE (optimistix LM / GN) | MAP (optax)
      │             | Bayesian (numpyro) | matched filter
      ▼
State estimate        x̂, Ŝ, averaging kernel

Four rules come out of the note and 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; 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. Grids, tables and SRFs stay leaves; what is not differentiated is chosen 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, never a Jacobian of a batch.

The proposed layout:

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. The notebook writes it with every array a leaf:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Shapes: L = 20 layers, G = 14,000 wavenumbers, B = 7 bands (8.5 nm)
# σ_l = bilinear(σ; ln p_l, T_l);  τ_l = σ_l n_l x_l Δz_l       (L, G)
tau = TableOpacity(log_p, t_grid, sigma)(atm)    # proposed (radtrax)
# L = (μ₀/π) A exp(−m Σ_l τ_l),  m = 1/μ₀ + 1/μ                  (G,)
rad = BeerLambert()(tau, albedo, mu0, mu)        # proposed (radtrax)
# y_b = Σ_g f_bg L(ν_g + δ)                                      (B,)
y = Instrument(srf, nu, shift)(rad)              # proposed (radtrax)
# one slab against plumax: L and ∂L/∂x                           (G,)
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
# K = ∂y/∂(ΔX₀, A, δ) per pixel, 256 pixels        (256, 3) → (256, 7, 3)
K = 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, radiance and Jacobian alike, to rounding; the Jacobian that plumax derives by hand comes out of jax.jacrev unchanged. The layers are not decoration. The same 1.9 ppm column (4.1×10194.1\times10^{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; 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 t(θs,θv,h,H2O)t(\theta_s, \theta_v, h, \text{H}_2\text{O}) with their gradientsslab equals plumax to 10-12 (done here); transmittance within 0.1 % of HAPI on AFGL US Standard; KK 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 XCH4X_{\text{CH}_4}, XH2OX_{\text{H}_2\text{O}}, 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 KK
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; 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 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\times10^{-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 within ε≈10−3\varepsilon \approx 10^{-3} Spurr, 2006; in JAX that branch needs the double-where (a safe denominator in the branch not taken) or jnp.where still propagates the NaN into the gradient.

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

  4. The boundary-value solve. Discrete ordinates ends in one block-tridiagonal system per Fourier component; implicit differentiation through it costs one more solve. gaussx’s BlockTriDiag solves that structure in O(Nd3)O(N d^3) and matches a dense solve to 5×10−165\times10^{-16}, but its band is symmetric (AkA_k below, Ak⊤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.

Fixed points (successive orders, SHDOM-like iterations) and the Gauss–Newton and Levenberg–Marquardt loops of optimal estimation use optimistix (FixedPointIteration, GaussNewton, LevenbergMarquardt, with ImplicitAdjoint), which plumax’s assimilation already depends on; 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; the table keeps what each code contributes to radtrax’s design. Entries marked † were checked for this page (the class names against ExoJAX’s documentation, the status against JAXopt’s README, the papers against their publication records); 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; v2’s line-by-line is differentiable in the line parameters too, 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; it is the one GeoNumerics addition v4 needs.

  4. Which sensor first for v1? EMIT has operational products to compare against; 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.