Writing Reusable Index Functions with xarray apply_ufunc
Write the index in NumPy once, then wrap it so it runs on labelled and chunked data without change:
import numpy as np
import xarray as xr
def _nd(a: np.ndarray, b: np.ndarray) -> np.ndarray:
den = a + b
return np.divide(a - b, den, out=np.full(den.shape, np.nan, "float32"), where=den != 0)
def normalised_difference(a: xr.DataArray, b: xr.DataArray) -> xr.DataArray:
return xr.apply_ufunc(_nd, a, b, dask="parallelized", output_dtypes=["float32"])
The NumPy core stays testable and fast; the wrapper takes care of coordinates, alignment and laziness. This page belongs to band math operations with xarray in Core Raster Fundamentals & STAC Mapping.
The Split Between Core and Wrapper
The benefit is testability as much as reuse. The core function takes and returns NumPy arrays, so it can be tested exhaustively with tiny synthetic inputs — including the overflow and zero-denominator cases — without constructing any DataArrays at all.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
xarray |
>=2024.1 |
apply_ufunc and labelled alignment |
numpy |
>=1.23 |
The core implementations |
dask[array] |
>=2024.1 |
Chunk-wise lazy execution |
numba |
>=0.59 |
Optional: compiling heavier per-pixel cores |
pip install "xarray>=2024.1" "numpy>=1.23" "dask[array]>=2024.1" "numba>=0.59"
Complete Working Example
from typing import Callable
import numpy as np
import xarray as xr
# ---- pure NumPy cores -------------------------------------------------------
def _nd(a: np.ndarray, b: np.ndarray) -> np.ndarray:
a, b = a.astype("float32", copy=False), b.astype("float32", copy=False)
den = a + b
return np.divide(a - b, den, out=np.full(den.shape, np.nan, "float32"), where=den != 0)
def _savi(nir: np.ndarray, red: np.ndarray, L: float = 0.5) -> np.ndarray:
nir, red = nir.astype("float32", copy=False), red.astype("float32", copy=False)
den = nir + red + L
return np.divide((1 + L) * (nir - red), den,
out=np.full(den.shape, np.nan, "float32"), where=den != 0)
# ---- a generic wrapper ------------------------------------------------------
def as_index(core: Callable, name: str, long_name: str) -> Callable[..., xr.DataArray]:
def wrapped(*bands: xr.DataArray, **kwargs) -> xr.DataArray:
out = xr.apply_ufunc(
core, *bands,
kwargs=kwargs,
dask="parallelized",
output_dtypes=["float32"],
keep_attrs="drop", # band attributes do not describe an index
join="exact", # refuse to silently align mismatched grids
)
out.name = name
out.attrs.update(long_name=long_name, units="1", valid_range=[-1.0, 1.0])
if bands[0].rio.crs is not None:
out = out.rio.write_crs(bands[0].rio.crs)
return out
wrapped.__name__ = name
wrapped.__doc__ = f"{long_name}, computed lazily per chunk."
return wrapped
ndvi = as_index(_nd, "ndvi", "normalised difference vegetation index")
savi = as_index(_savi, "savi", "soil adjusted vegetation index")
join="exact" is the argument most people omit and later need. By default apply_ufunc performs an inner join on coordinates, so two bands whose grids differ by a fraction of a pixel are silently cropped to their intersection. exact raises instead, forcing the grids to be aligned deliberately — which is exactly the discipline described in aligning two rasters with reproject_match.
Core Dimensions for Temporal Functions
import numpy as np
import xarray as xr
def _amplitude(series: np.ndarray) -> np.ndarray:
"""Seasonal amplitude over the LAST axis, ignoring NaN."""
return (np.nanpercentile(series, 90, axis=-1)
- np.nanpercentile(series, 10, axis=-1)).astype("float32")
def seasonal_amplitude(ndvi_series: xr.DataArray) -> xr.DataArray:
series = ndvi_series.chunk({"time": -1}) # the core dim must be one chunk
return xr.apply_ufunc(
_amplitude, series,
input_core_dims=[["time"]], # moved to the last axis
dask="parallelized", output_dtypes=["float32"],
)
The rechunk to a single time chunk is not optional: apply_ufunc passes each chunk to the core independently, so a time axis split across chunks would hand the function fragments of each series and the percentiles would be computed on the wrong samples. The same constraint shapes the chunking advice in building temporal feature stacks from image time series.
When Not to Use apply_ufunc
For formulas that xarray arithmetic expresses directly — every normalised difference, every ratio, most linear combinations — plain operators are clearer, equally lazy and equally fast. (nir - red) / (nir + red) on Dask-backed DataArrays builds the same graph apply_ufunc would.
The wrapper earns its place in three situations. When the core is written in something that does not understand xarray, such as a Numba-compiled per-pixel loop or a function from a compiled library. When the function works along an axis and needs explicit core dimensions. And when the same logic must run on plain NumPy arrays elsewhere in the codebase — a model inference script, say — and duplicating it in two styles would let them drift apart. Outside those cases, the simpler expression is the better one.
Verification
import numpy as np
import xarray as xr
a = np.array([[400, 0, 60000]], dtype="uint16")
b = np.array([[3000, 0, 62000]], dtype="uint16")
A = xr.DataArray(a, dims=("y", "x"))
B = xr.DataArray(b, dims=("y", "x"))
core = _nd(b, a)
eager = ndvi(B, A).values
lazy = ndvi(B.chunk({"x": 1}), A.chunk({"x": 1})).compute().values
for other in (eager, lazy):
assert np.array_equal(np.isnan(core), np.isnan(other))
assert np.allclose(core, other, equal_nan=True)
The synthetic input deliberately includes a zero denominator and values near the uint16 maximum, so the test covers the two numerical hazards alongside the plumbing. The overflow case in particular is described in avoiding integer overflow in index calculations.
Common Errors
ValueError: dimension time on 0th function argument consists of multiple chunks
A core dimension is split across chunks. Rechunk it to a single chunk before calling.
The result is smaller than the inputs
The default inner join cropped mismatched grids. Use join="exact" and align explicitly.
The result is computed immediately
dask="parallelized" was omitted, so xarray loaded the inputs. Add it along with output_dtypes.
The output lost its CRS
apply_ufunc does not carry rioxarray’s spatial reference. Write it back onto the result, as the wrapper does, taking it from the first input band.
Frequently Asked Questions
Q: Why use apply_ufunc if xarray arithmetic already works? For simple formulas plain arithmetic is better. apply_ufunc earns its place when the core function is written in NumPy, Numba or a compiled library that does not understand xarray, or when it needs control over dimensions, dtypes and vectorisation that operator overloading does not give.
Q: What does dask=‘parallelized’ do? It tells xarray to apply the function independently to each Dask chunk, producing a lazy result. The function itself only ever sees plain NumPy arrays, so it needs no Dask awareness at all.
Q: Why must output_dtypes be given? Because a lazy result needs a dtype before anything is computed. Without it xarray would have to run the function on sample data to discover one; with it, the graph is built instantly and the dtype is guaranteed.
Q: Can the core be a Numba function?
Yes, and it is a good fit for per-pixel logic too complex for vectorised NumPy, such as an iterative fit per series. Compile it with numba.njit or guvectorize, keep its signature on plain arrays, and wrap it exactly as shown.
Related
- Band Math Operations with xarray — the parent topic.
- Computing Band Ratios with Dask-Backed DataArrays — the plain-arithmetic route for simple ratios.
- Building a YAML-Driven Multi-Index Pipeline — registering functions like these in a configurable pipeline.
- Rasterio vs xarray for Band Math — when each tool fits.