xrops.grad — finite-difference calculus¶
xrops.grad provides partial derivatives and vector-calculus operators (∇, ∇·, ∇×, Δ) on labeled xarray grids, and the Gradient and Difference operators over them. The stencils are geoops's diagnostic difference kernel (geoops.geom.difference, geostack OP-O7), which follows finitediffx's rules on NumPy; xrops.grad adds the xarray layer. It replaces the former xrtoolz-grad (xrgrad) distribution, with the same functions and numbers.
Geometry dispatch¶
Every operator takes geometry=, which selects how coordinate values
become physical distances:
| Geometry | Assumes | Spacing |
|---|---|---|
"cartesian" |
uniform grid, coords in metres | single Δ read from the coord, validated uniform (uniform_rtol) |
"rectilinear" |
non-uniform 1-D coord per dim, in metres | true coordinate differences |
"spherical" |
lon/lat in degrees | metric factors below |
For the spherical case, derivatives are taken in radians and scaled by the metric factors
with \(\lambda\) longitude, \(\varphi\) latitude, and \(R\) the Earth radius
(grad.EARTH_RADIUS, 6 371 000 m).
Mixed geometry¶
divergence also accepts a per-dim sequence of geometries, one per
entry in dims. The common case is a 3-D ocean budget: spherical
horizontal, rectilinear vertical.
div = grad.divergence(
ds,
("flux_e", "flux_n", "flux_w"),
dims=("lon", "lat", "depth"),
geometry=("spherical", "spherical", "rectilinear"),
)
The -(F_y \tan\varphi)/R curvature term is applied when both the
longitude and latitude axes are spherical. Marking only one of them (or
marking the vertical axis spherical) raises — the metric couples the
pair, so neither is meaningful alone. Geometry-specific keywords are
routed only to the backends that accept them, so radius= may
accompany a mixed call without reaching the vertical axis.
Temporal coordinates¶
datetime64 and timedelta64 coordinates work anywhere a numeric one
does; they are converted to seconds, so ∂c/∂t comes back per
second. Values are anchored at the first sample before conversion, which
keeps nanosecond steps representable in float64. A uniform time axis
takes the cartesian path; an irregular one (a skipped day, monthly
means) needs geometry="rectilinear". cftime/object-dtype calendars
are rejected with an explicit error rather than silently mis-stepped.
All geometries expect the differentiated dimension to carry a 1-D
coordinate: step sizes come from coordinate values, so a dimension
without one raises ValueError, and 2-D (curvilinear) coordinates are
rejected rather than silently producing a nonsense step. "rectilinear"
additionally requires the coordinate to be strictly monotonic —
repeated or reversing values would divide by a zero coordinate
difference. Strictly descending coordinates are fine, as are scalar
coordinates on axes a given derivative does not use (a lat derivative
still works after da.sel(lon=...)).
Curvature corrections¶
On the sphere, divergence and curl are not plain sums of partials —
the unit vectors themselves rotate with longitude. xrops.grad adds the
curvature terms so results match the equivalent metpy.calc operators
on lon/lat fields:
On spherical and rectilinear geometries the Laplacian is composed as
\(\Delta f = \nabla\cdot\nabla f\), so it inherits the corrections
automatically. Only on Cartesian grids, where there is no curvature term
to inherit, does laplacian instead sum direct second-derivative
stencils \(\sum_d \partial^2 f/\partial d^2\) — see below.
Accuracy and stencils¶
accuracy= is finitediffx's accuracy order; method= selects
"central" (default), "forward", or "backward" stencils. Interior
points use the requested central stencil; boundary points fall back to
one-sided differences of the same order. Two practical notes:
- First derivatives of polynomials up to the stencil order are exact on
interior points at
accuracy=1. partialtakes anorder=argument for higher derivatives.order=2uses a single second-derivative stencil rather than two composed passes. It is available on Cartesian grids (and on rectilinear ones whose coordinate turns out to be uniform); spherical geometry and genuinely non-uniform coordinates raiseNotImplementedError.laplaciansumsorder=2stencils only forgeometry="cartesian", so there it is already exact for quadratic fields at the defaultaccuracy=1and degrades only a single boundary ring. Every other geometry — spherical, and all rectilinear grids, including ones whose coordinate is uniform enough that each partial delegates to the Cartesian backend — still composes two first-derivative stencils, so passaccuracy=2(or higher) there when the interior must reproduce quadratic fields exactly.divergence,curl, andlaplacianaccept a per-dimaccuracytuple as well as a scalar, matchinggradient. The tuple pairs withdimsin order, which is useful on anisotropic grids.
Periodic boundaries¶
By default the first and last points of an axis fall back to one-sided
stencils. On a genuinely periodic axis — global longitude being the
usual case — that leaves a visible seam at the dateline. Pass
periodic= to wrap instead, so those points are differentiated against
the opposite edge and are as accurate as the interior:
partial takes a bool (it acts on one axis); the multi-dim operators
take a dimension name or a sequence of names, which must be among the
dims being differentiated.
The axis is treated as endpoint exclusive — the period is n * step,
so the last sample sits one step before the first repeats. This is what
np.linspace(0, L, n, endpoint=False) and a 0…355 global longitude
axis give you. A grid that stores both ends of the period (numpy's
default endpoint=True) repeats a physical location, which makes the
seam point its own neighbour and roughly halves its derivative; drop the
duplicate with da.isel(x=slice(0, -1)) first.
Which convention a grid uses cannot be read off the coordinate — both
are uniform with the same step — so on the Cartesian path this is only a
UserWarning, raised when the field happens to take the same value at
both ends. A genuinely endpoint-exclusive field can do that too (period
3 over [0, 1, 2] with values [0, 1, 0]), so the warning is advisory
and the computation proceeds. Spherical longitude is the exception: its
period is known, so a grid that does not span exactly 360° is a hard
error.
Further restrictions:
- Spherical longitude must span the full 360°; a regional grid has no wrap-around neighbour and raises.
- Latitude never wraps — the poles are a separate problem.
- Non-uniform rectilinear axes raise: wrapping the index grid would also have to wrap the spacing, which has no single period.
import numpy as np
import xarray as xr
from xrops import grad
x = np.linspace(0.0, 3.0, 4)
X, Y = np.meshgrid(x, x, indexing="xy")
coords = {"y": x, "x": x}
ds = xr.Dataset(
{
"u": xr.DataArray(-Y, dims=("y", "x"), coords=coords),
"v": xr.DataArray(X, dims=("y", "x"), coords=coords),
}
)
zeta = grad.curl(ds, ("u", "v"), dims=("x", "y")) # rigid rotation → 2
div = grad.divergence(ds, ("v", "u"), dims=("x", "y")) # ∇·(x, -y) → 0
For a worked spherical example (geostrophic currents and vorticity from sea-surface height), see the quickstart notebook.
NaN and land masks¶
A stencil that reads a NaN returns NaN. By default that is exactly what
happens, so each masked cell costs a halo of roughly accuracy valid
cells in every differentiated direction — on a masked ocean field, a
strip of water lost all the way around every coastline, silently.
nan_policy="adaptive" picks, per point, the widest stencil lying
wholly on finite data: centred where the full support is valid, then
forward, then backward. Masked cells stay NaN.
zeta = grad.curl(
ds, ("u", "v"), dims=("lon", "lat"),
geometry="spherical", nan_policy="adaptive",
)
Measured against an analytic field with a synthetic coastline
(docs/design/examples/xrgrad-nan-mask-comparison.py), at the cells
adjacent to land:
| cells lost | RMS relative error | |
|---|---|---|
propagate (default) |
62% | 5.5e-05 on the survivors |
| fill, differentiate, re-mask | 0% | 6.5e-01 |
nan_policy="adaptive" |
0% | 7.2e-03 (accuracy=1), 1.4e-04 (accuracy=2) |
Two things to take from that:
- Do not gap-fill a field to fix its derivative. Harmonic filling
solves
∇²u = 0, so the filled gradient is an artefact of the fill — four orders of magnitude worse than losing the cells, and invisible because the output looks complete. Thefillnan_*family inxrops.interpolateis the right tool when you want a gap-free field, not for derivative accuracy near a mask. - Coastal cells cost one order of accuracy, since they use a
one-sided stencil. Raising
accuracybuys it back, as the table shows. Interior cells are bit-identical topropagate. - Raising
accuracynever loses cells. A region narrower than the requested stencil — a two-cell channel between islands, say — falls back to the widest lower accuracy that fits there, so recovery is monotone inaccuracyand only the local error order varies. The same descent applies when the whole axis is shorter than the stencil. A single isolated cell still has no neighbour to difference against and stays NaN. - Cost is up to
3 * accuracystencil passes on the requested axes, against one forpropagate. The sweep exits as soon as no valid cell is unresolved, so an unmasked field costs a single pass and an ordinary coastline costs three; the upper bound is only reached on masks fragmented into strips narrower than the requested stencil.
method= cannot be combined with nan_policy="adaptive" — the fallback
chain is itself method selection — and the option composes with
periodic=, so a masked global field can wrap and adapt in one call.
Grid metrics and constants¶
grid_metrics_from_coords derives the cell-width / face-area Datasets
consumed by xrops.budgets from a Dataset's lon/lat (and optional
depth) coordinates. The physical constants EARTH_RADIUS, GRAVITY,
and OMEGA are exported at the top level and replace the equivalent
metpy.constants lookups.