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.

integrix: integrals over space

integrix computes one integral, I=∫ΩfdμI = \int_\Omega f \mathrm{d}\mu, as a rule, a scan or a march, and Waves 0 to 4 built all three. The steppers and their adjoints took the most work, so the library reads as a time library, but only ix.march and ix.adjoint are about time: ix.rule, ix.scan, ix.adapt and ix.expect integrate over whatever axis they are given. This note asks what is still missing when that axis is space: an area, a set of cells, or a line across a plume. It records four decisions (10 to 13, after the nine in the integrix design). The maintainer settled all four on 10 Nov 2026, each as recommended (integrix#47).

Summary

#DecisionRecommendedAlternative
10The shape of a multi-dimensional rule’s weightsfunctax’s grid rules keep the grid’s shape: cell areas on an Ny×NxN_y \times N_x grid are an Ny×NxN_y \times N_x arrayflattened weights, and ix.rule reshapes the values
11Masks and NaNsix.rule.integrate and ix.rule.weighted_mean take where= and nan_policy="propagate" | "omit"callers fold masks into the weights and zero their NaNs
12A weight function with its own batch axesweighted_mean’s weight may carry leading axes, one result per leading indexthe caller vmaps weighted_mean
13Line integrals and fluxesfunctax owns rules on curves (nodes, arc-length weights, normals); integrix contracts them and adds no line primitivean ix.rule.flux(curve, field, velocity) that samples the field itself

Which integrals are spatial, and how they differ

The axes differ in ways that matter to the code, even though each case is one integral.

AxisWhat makes it differentintegrix today
Timecausal: the integrand depends on the solution, so it is stepped, with stability limits and adjoints that store the pastix.march, ix.adjoint
A vertical columnone dimension, samples on given levels, often cumulative (optical depth)ix.rule, ix.scan
An area or a volumetwo or three dimensions, cells of unequal area (projected or on the sphere), masks, NaN pixelsix.rule with a weights array; the weights are the caller’s until functax’s cell-area rules land
A line across a fielda flux through a transect, ∫cdℓ\int c \mathrm{d}\ell or ∫c(u⋅n)dℓ\int c (\vect{u} \cdot \vect{n}) \mathrm{d}\ellnothing
A spectral banda response function weights the band; lines can be narrow and peakedix.rule.weighted_mean, ix.adapt.GaussKronrod
DirectionsGauss rules in μ=cos⁡θ\mu = \cos \theta, or nodes on the spherethe rules are functax’s and gaussx’s; integrix only sums (no caller yet)
A probability spacea measure to sample fromix.expect, and gaussx for Gaussians

Time and probability are covered. The spatial rows are where the stack’s code does things ix.rule cannot yet express.

Running example: a methane plume on a 20 m grid

TL;DR. From one image of a methane plume we want two numbers: the mass of methane in the plume, and the flux of methane through lines drawn across it downwind. Both are spatial integrals, one over an area and one along a line, and both must skip cloudy pixels.

Problem. An image on a projected grid has Ny×NxN_y \times N_x pixels of side w=20w = 20 m and area ΔA=w2\Delta A = w^2. In each pixel ii, Ωi\Omega_i is the methane column enhancement in kg m⁻², NaN where the pixel is cloudy. A boolean plume mask mim_i marks the plume, and u\vect{u} is the wind vector in m s⁻¹ with speed U=∥u∥U = \lVert \vect{u} \rVert. The integrated mass enhancement over the plume PP is

IME=∫PΩdA≈∑i∈PΩiΔA,\text{IME} = \int_P \Omega \mathrm{d}A \approx \sum_{i \in P} \Omega_i \Delta A,

and the cross-sectional flux through a transect LL across the wind, with n\vect{n} its unit normal, is

Φ=∫LΩ(u⋅n)dℓ.\Phi = \int_L \Omega (\vect{u} \cdot \vect{n}) \mathrm{d}\ell .

Today plumax computes both with NumPy at plumax 208717e, in src/plumax/geo/plume.py:

Two other consumers integrate over space with different NaN rules:

So the same contraction is wanted with NaNs skipped (plumax) and with NaNs propagated (xrops), over a masked area, along a line, and against a whole stack of response functions at once.

What integrix offers today

ix.rule.integrate(rule, values, *, axis=None) contracts the values with a rule’s weights over one axis or several, batched over the rest, and returns an Integral. The rule is anything with weights (functax’s Rule), a tuple of 1-D rules (a tensor rule), or a plain weights array of any rank, whose shape must equal the values’ shape along the integrated axes. ix.rule.weighted_mean(rule, values, *, weight, axis=None) divides ∑wRf\sum w R f by ∑wR\sum w R, with RR one value per node or per sample. Sums are pairwise, and weights take the values’ dtype.

So ix.rule.integrate(jnp.full((ny, nx), area), enhancement) already computes an unmasked IME. What it cannot do is skip the cloudy pixels, keep the gradient finite when a NaN pixel is masked out, take BB response functions in one call, or integrate along a line.

Decision 10: the weights of a grid rule keep the grid’s shape

Question. When functax returns a rule for a two-dimensional grid (cell areas, functax#59), are its weights an Ny×NxN_y \times N_x array or a flat vector of NyNxN_y N_x?

Why now. functax#59 sketches cell_area(grid) -> Rule with nodes of shape (NyNx,2)(N_y N_x, 2) and weights of shape (NyNx,)(N_y N_x,). ix.rule.integrate matches the weights’ shape to the values’ integrated axes, so those weights cannot meet an Ny×NxN_y \times N_x image without a reshape. integrix’s own design sketch, ix.rule.integrate(pixel_rule, enhancement) with pixel_rule = fx.quad.cell_area(plume_grid) of shape (Ny,Nx)(N_y, N_x), assumes the other layout.

# Shapes: Ny × Nx cells

# proposed (functax)
pixel_rule = fx.quad.cell_area(plume_grid)  # weights (Ny, Nx), nodes (Ny, Nx, 2)
# proposed (integrix), already what ix.rule.integrate does with a weights array
# IME = ∑ᵢ Ωᵢ ΔAᵢ
ime = ix.rule.integrate(pixel_rule, enhancement).value  # (Ny, Nx) → ()

B. Flattened weights

# proposed (functax), as functax#59 sketches it
pixel_rule = fx.quad.cell_area(plume_grid)  # weights (Ny·Nx,), nodes (Ny·Nx, 2)
# proposed (integrix): a new rule shape that flattens the values to match
ime = ix.rule.integrate(pixel_rule, enhancement, axis=(-2, -1)).value
CriterionAB
Works with ix.rule todayyesno: needs a flatten rule in integrix
Matches a raster’s layout (xarray, GeoTensor)yesno
Scattered points (no grid)the same rule with one node axisthe same
Spherical cellsyes, weights in m² on a sphere of radius aayes

Decided (10 Nov 2026): A, as recommended. The rule’s weights keep the grid’s shape and its nodes gain a trailing coordinate axis. A rule over scattered points keeps one node axis, as now. Alternative: B, if functax wants every rule to have exactly one node axis; integrix would then flatten the values’ integrated axes in row-major order.

Decision 11: masks and NaNs, with where= and nan_policy=

Question. How does a caller integrate over a region (a plume mask, a coastline) and decide what a NaN sample means?

The trap. Folding a mask into the weights, ∑i(wimi)fi\sum_i (w_i m_i) f_i, does not skip NaNs: 0×NaN0 \times \text{NaN} is NaN. Zeroing the NaNs first with jnp.where(jnp.isnan(f), 0, f) fixes the value but not always the gradient. If ff came from a computation that produced the NaN, the untaken branch of the where still sends NaN back through reverse mode. A safe version needs the guarded double where from integrix’s CODE_REVIEW, and every caller would rewrite it.

# Shapes: Ny × Nx pixels

# proposed (integrix)
# IME = ∑ᵢ mᵢ Ωᵢ ΔAᵢ, NaN pixels skipped (np.nansum's meaning)
ime = ix.rule.integrate(pixel_rule, enhancement, where=plume_mask, nan_policy="omit").value  # () kg
# proposed (integrix): xrops keeps its NaN-propagating column
total = ix.rule.integrate(level_rule, density, axis=-1, nan_policy="propagate").value  # (…, Nz) → (…)

B. The caller folds the mask into the weights

# today (plumax), as a JAX call: the caller guards the NaNs itself
safe = jnp.where(plume_mask & ~jnp.isnan(enhancement), enhancement, 0.0)
ime = ix.rule.integrate(pixel_rule, safe).value
CriterionAB
NaN-safe gradientyes, guarded once in integrixonly when each caller guards correctly
Matches np.nansum and xrops’ NaN ruleboth, by nan_policyeach caller re-derives it
weighted_mean renormalisationbuilt inthe caller divides by a second masked sum
jit / vmapyes: static shapes, the mask is datayes

Decided (10 Nov 2026): A, as recommended, on ix.rule.integrate and ix.rule.weighted_mean; it shipped in integrix 0.0.4. Alternative: B, with a documented recipe in integrix’s guide. ix.scan.cumulative is left out: a running integral with skipped samples has no single obvious meaning, so a caller who needs it can ask.

Decision 12: a weight function with its own batch axes

Question. Can weighted_mean take BB response functions at once, Lˉb=∑kwkRbkLk/∑kwkRbk\bar L_b = \sum_k w_k R_{bk} L_k / \sum_k w_k R_{bk}?

Why. radtrax’s SpectralResponseFunction stores a B×GB \times G response matrix (srf.py @ 0aff245), and radtrax#307 leaves open whether weight may carry the band axis. Today weight must have the rule’s shape or the values’ shape, so a caller writes jax.vmap(lambda r: ix.rule.weighted_mean(rule, radiance, weight=r))(response).

# Shapes: B bands; G wavelengths; P pixels

# proposed (integrix)
# L̄_b = ∑ₖ wₖ R_bk Lₖ / ∑ₖ wₖ R_bk
bands = ix.rule.weighted_mean(rule, radiance, weight=response)  # (P, G), (B, G) → (P, B)

The leading axes of weight beyond the rule’s shape are batch axes of the weight function. The result has the values’ batch axes first, then the weight’s: (P,B)(P, B), a spectrum of band values per pixel.

B. vmap at the call site (today)

# today (integrix): a vmap over bands
bands = jax.vmap(lambda r: ix.rule.weighted_mean(rule, radiance, weight=r), out_axes=-1)(response)  # (P, B)
CriterionAB
One call per instrumentyesno
Ambiguity with the per-sample weightresolved by shape: a per-sample weight has the values’ shapenone
Costone contraction, O(PBG)O(PBG)the same under vmap

Decided (10 Nov 2026): A, as recommended, in ix.rule.weighted_mean only; it shipped in integrix 0.0.4. Alternative: B, with vmap documented in the guide; it already works.

Decision 13: line integrals and fluxes

Question. Where do line integrals live, and what does integrix add for them?

A line integral has two discretisations, and the stack uses one of them today.

# Shapes: K nodes on the transect; Ny × Nx grid

# proposed (functax): a rule on a polyline, with the unit normal at each node
transect = fx.quad.polyline(vertices, n=K)  # nodes (K, 2), weights Δs (K,), normals (K, 2)
# today (functax): sample the gridded fields at the nodes
plan = fx.interp.Plan(...)  # the field read-out H at transect.nodes
# proposed (integrix): Φ = ∑ₖ Δsₖ Ωₖ (uₖ · nₖ)
normal_wind = einx.dot("k d, k d -> k", wind_at_nodes, transect.normals)
flux = ix.rule.integrate(transect, plan(enhancement) * normal_wind).value  # () kg s⁻¹
# proposed (integrix): the strip form, as plumax computes it today
flux_strip = ix.rule.integrate(pixel_rule, enhancement, where=in_strip & plume_mask, nan_policy="omit").value * wind_speed / w

B. ix.rule.flux in integrix

# proposed (B): integrix samples the field and forms u · n itself
flux = ix.rule.flux(transect, enhancement, wind, grid=plume_grid)
CriterionAB
Ownership (decision 3: rules are functax’s)kept: the curve rule is dataintegrix would choose an interpolation scheme
New integrix APInoneone function, with grid and interpolation options
Reuse by tracerax and radiomax (line-of-sight and pixel-footprint integrals)the same curve rulea second entry point
plumax’s strip formwhere= from Decision 11still needs where=

Decided (10 Nov 2026): A, as recommended. functax adds rules on curves, a Rule whose nodes lie on a polyline and whose weights are arc lengths, with the unit normals alongside (functax decides whether the normals are a field of the rule or a separate return). integrix adds nothing line-specific, and its guide shows both forms. Alternative: B, if the same three-step composition turns up in several consumers.

Out of scope

Gates

GatePasses when
G6-maskwith nan_policy="omit" and where=, ix.rule.integrate equals np.nansum(f[mask] * w[mask]) to 1e-14 relative in float64, on random fields with NaNs; with "propagate", a NaN among the selected samples gives NaN
G6-gradgradients with respect to the kept samples and the weights match central differences to 1e-6, and are finite and exactly zero for masked and NaN samples
G6-batchweighted_mean with a (B,G)(B, G) weight equals the vmap over bands to 1e-14
G6-lineon a Gaussian plume Ω(x,y)=QU2πσe−y2/2σ2\Omega(x, y) = \frac{Q}{U \sqrt{2 \pi} \sigma} e^{-y^2 / 2 \sigma^2}, the sampled form of ∫ΩUdy\int \Omega U \mathrm{d}y across the wind recovers QQ to the rule’s discretisation error, and the strip form equals plumax’s CrossSectionalFlux at 208717e to 1e-12
G6-portplumax’s IMEEstimate and CrossSectionalFlux on ix.rule reproduce their results at 208717e to 1e-12, on the repository’s own test fixtures

Roadmap

These become Wave 6 of integrix, an epic in integrix under geostack#136, with one issue per decision that needs code:

Sources