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.

GeoStack operators: geoops and xrops on one kernel model

Design · needs × carrier families, kernels written once, package tiers

1Scope

This page decides how operations are organised across geoops (geotoolz-ops) and xrops (xrtoolz-ops): what each package writes, what it reuses, and where every kind of operation lives, so nothing is written twice. It sits between two others:

This page adds no machinery of its own; where it needs some, the need goes to pipekit.

Decided 22 Oct 2026: xrops sits one tier above geoops (package tiers); this settles OP-O1, OP-O4 and OP-O7. The levels (L1 primitive, L2 operator, L3 algorithm) are defined once, in the three levels; this page places operations within them. Still open: the case studies findings CS-F1 to CS-F17.

2The grid

Two independent questions place every operation: what it needs to know (a row) and how much data it runs on (the carrier family, a column).

Needsgeoarray carriers (one item, eager or jit)xarray carriers (many items, by value, lazy)
geometrygeoops: reproject, rasterize, sample H(x), plume detection, patch/stitchxrops: regrid a cube, mosaic many files
dim namespipekit-array dim-level functions; KernelOps over a named dimthe same functions and KernelOps; native value operations (resample, groupby, ranges); xrops analysis
shapes onlykernels on .datakernels via apply_ufunc

The two carrier families:

FamilyCarriersGood at
geoarrayGeoArray, GeoSwath, GeoPointsone item or a fixed batch: strict grids, compiles, ~1–10 µs per op
xarrayDataArray, Dataset, DataTreemany items: selection by value, laziness, out-of-core, variables with different dims

The five kinds of operation and their owners:

KindOwnerRuns on
L1 kernelsgaussx, somax, radtrax, … and geoops’s array.py modulesraw arrays
dim-level functionspipekit-arrayevery registered carrier
KernelOpswrapped by geoops or xrops, machinery in pipekit-arrayevery registered carrier
geometry operatorsgeoops (per item), xrops (per cube)their column
native xarray operationsxarrayxarray carriers

The consequence that matters most: an operation that needs only dim names, built as a KernelOp, is not column-specific. NDVI(dim="band") runs on a GeoImage eagerly, on a JAX carrier under jit, and on a lazy cube through apply_ufunc, from one definition. Only geometry operators and xarray’s value operations stay tied to a column.

3Package tiers: xrops above geoops (decided)

Decided 22 Oct 2026: pipekit-array (tier 0) → geoops (tier 1) → xrops (tier 2). Every kernel is written once, in geoops or below, and runs on xarray through the pipekit-array adapter; xrops keeps native-xarray work and the state-physics kernels.

pipekit-array        tier 0: adapters, KernelOp, dim-level functions
     │               runs any kernel on NumPy, JAX and xarray
     │   [xarray] extra ──▶ xarray
     ▼
geoops               tier 1: every kernel operator, written once;
     │               geometry on carriers; no xarray in its base
     ▼
xrops                tier 2: native-xarray work (sel by value, groupby,
                     many files, Dataset/DataTree), xrops.lift(geoops op),
                     state physics; imports geoops and xarray

Arrows point from a package to what it imports: geoops reaches xarray only through pipekit-array’s [xarray] adapter, never directly.

Carriers versus operations

Supporting an xarray carrier is not being an xarray operation: an operator unwraps the carrier, runs its kernel and rewraps the result.

ThingExamplesWho owns itA GeoStack operator…
xarray data structures (carriers)DataArray, Dataset, DataTreexarrayaccepts and returns them through the adapter
native xarray operationssel, groupby, resample, rolling, interp, accessors such as .rio, .xvecxarray and its extensionscalls them only inside xrops
GeoStack operatorsNDVI, MatchedFilter, Gradient, Reprojectgeoops (kernels, geometry), xrops (native-xarray work, state physics)is a function or Operator taking a carrier, never an accessor

No accessors: an accessor registers globally on every DataArray and hides the import that provides it. The scikit-learn comparison holds on the main point and breaks on two details:

scikit-learn set_output(transform="pandas")pipekit-array adapter
separate package per containerno (there is no “pdlearn”)no (xrops writes no kernel twice)
what is unwrappeda 2-D table, converted to NumPy at entrynamed N-D arrays, unwrapped through apply_ufunc
dask inputcomputed eagerlystays lazy, one task per chunk
which axes a step consumesalways the feature axisdeclared by the kernel’s signature, e.g. "(c)->()" with c = band
output metadatacolumn namescoords and attrs of the surviving dims

Prerequisite: a slim geoops base install

The tier change is only cheap if pip install geotoolz-ops stops pulling the vector and sensor stack. This split was taken as the recommended option without a strong preference either way, so it stays reversible until the geotoolz issue lands.

Dependency (base today)HomeWhy
numpy, scipy, jaxtyping, typing-extensions, pipekit, einxbasekernels and KernelOps
affine, pyprojbasetransforms and CRS on carriers
pandasbase, unless an import audit finds it only in vector codesmall, often already installed
rasterio[raster]reprojection and rasterizing; xrtoolz already pulls it via rioxarray
geopandas, shapely[vector]polygons and points as tables
scikit-image[morph]morphology and segmentation
georeader-spaceml[sensors]sensor readers and geometry

Gate: a fresh pip install geotoolz-ops installs none of rasterio, geopandas, shapely, scikit-image or georeader; import geoops succeeds; an operator that needs an extra raises an ImportError naming it. A geotoolz issue for the slim base blocks the tier change.

Costs of the choice

Tiering won on where code lives; its costs are install weight (removed by the slim base) and the lost symmetry of the two lanes.

Chosen: tieredNot taken: two lanes, same tier
lifting geoops over cubes (OP-O4)xrops.lift, in the package that knows xarraygeoops.lift behind geoops[xarray]
gradient (OP-O7)kernel and carrier wrapper in geoops, xarray wrapper in xropskernel in pipekit-array, which stops being domain-free
xrops reusing geoops operatorsimports them and can type-check them as argumentsonly by composing in a pipekit pipeline
installing xrops aloneadds geoops’s slim basexarray stack only
state physics (MLD, budgets) on a geoarray carrierlives in xrops, so xarray must be installedsame
import xropsalso imports geoops (not measured)imports xarray only
runtime speedsamesame
symmetry of the lanesgone: geoops can never use an xrops kernelkept

Demo API

How one kernel runs on every carrier, eagerly, under jit and lazily through apply_ufunc, is shown once, in KernelOp. NDVI (defined in the geoops section) is written once in geoops; no xrops.NDVI exists, and the xarray call works because pipekit-array’s [xarray] adapter is registered, not because xrops is installed. What tiering adds is xrops composing geoops; lifting over a cube and the gradient have their own sections below.

xrops composing geoops.

# monthly NDVI climatology over a lazy cube: a geoops kernel, then a by-value reduction
# clim(m, y, x) = (1 / |Tₘ|) ∑_{t ∈ Tₘ} NDVI(t, y, x), Tₘ the dates in month m
ndvi: Operator = geoops.NDVI()
climatology: Operator = xrops.Climatology(op=ndvi, freq="month")
clim: DataArray["month y x"] = climatology(cube)

# the same as a pipekit pipeline
monthly: Operator = xrops.Climatology(freq="month")
pipe: pipekit.Pipeline = ndvi | monthly
clim_2: DataArray["month y x"] = pipe(cube)

The pipeline form worked under the two-lane design too; tiering adds the first, where an xrops operator takes a geoops operator as an argument and type-checks it.

4The levels in practice

Every package that computes writes its numerics as L1 primitives on arrays, L2 operators on carriers above them, and L3 algorithms on readable geo types at the top; the rules are in the three levels. Three examples, one per level, from the GeoNumerics and GeoModels side of the stack.

L1: a primitive (finitevolX)

# finitevolx/_src/array.py — arrays in, arrays out, no carrier, no CRS
class CGrid(eqx.Module):
    dx_u: Float[Array, "H W"]  # zonal spacing at u points, m
    dy_v: Float[Array, "H W"]  # meridional spacing at v points, m
    area_t: Float[Array, "H W"]  # cell area at T points, m²
    mask_t: Bool[Array, "H W"]  # ocean = True


def divergence(
    u: Float[Array, "... H W"],  # zonal velocity on u points
    v: Float[Array, "... H W"],  # meridional velocity on v points
    grid: CGrid,
) -> Float[Array, "... H W"]:
    # ∇·𝐮 ≈ [δₓ(u Δy) + δᵧ(v Δx)] / A, zero on land
    ...

L2: an operator on a carrier (somax)

# somax/geo/operators.py — behind somax[geo]; GeoModels may import GeoStack and GeoNumerics
class Divergence(Operator):
    def __call__(
        self, u: GeoArray["... y x"], v: GeoArray["... y x"]
    ) -> GeoArray["... y x"]:
        # derived geometry, once per call: transform + CRS → spacings and areas (pyproj lives here)
        grid: CGrid = cgrid_from_transform(u.transform, u.crs, mask=u.valid)
        # the L1 call on raw arrays
        div: Float[Array, "... H W"] = divergence(u.data, v.data, grid)
        # rewrapped on the T-point grid
        return u.rewrap(div, dims=u.dims)

L3: a simulator and an assimilator (somax, vardax)

# somax/geo/simulate.py — linear shallow water, η the SSH anomaly
def simulate(
    eta0: GeoMap,  # GeoArray["y x"], initial state
    times: Int[np.ndarray, "T"],  # output times tₖ, s after eta0's time; concrete
    depth: float,  # H, m
    dt: float,  # Δt, s
    mask: Bool[Array, "H W"] | None = None,  # ocean cells; None means eta0.valid
) -> GeoImageSeries:  # GeoArray["time y x"], one frame per requested time
    # entry, before tracing: output k is reached after nₖ = tₖ / Δt steps; n = 0 is η₀
    n_out: Int[np.ndarray, "T"] = np.rint(times / dt).astype(int)
    off_grid: bool = bool(np.any(n_out * dt != times))
    gaps: Int[np.ndarray, "T-1"] = np.diff(n_out)
    unsorted: bool = bool(np.any(gaps < 0))
    if off_grid or unsorted or n_out[0] < 0:
        raise ValueError("times must be sorted, non-negative multiples of dt")
    n_total: int = int(n_out[-1])
    # entry: carrier → arrays and derived geometry, once; the domain is the mask the
    # caller passes, so finite placeholders on land never turn land into ocean
    ocean: Bool[Array, "H W"] = eta0.valid if mask is None else mask
    grid: CGrid = cgrid_from_transform(eta0.transform, eta0.crs, mask=ocean)
    eta_init: Float[Array, "H W"] = eta0.data
    zeros: Float[Array, "H W"] = jnp.zeros_like(eta_init)
    # (η, u, v), each (H, W)
    state0: tuple[Float[Array, "H W"], Float[Array, "H W"], Float[Array, "H W"]] = (
        eta_init,
        zeros,
        zeros,
    )

    def step(
        state: tuple[Float[Array, "H W"], ...], _: None
    ) -> tuple[tuple[Float[Array, "H W"], ...], Float[Array, "H W"]]:
        eta, u, v = state
        # ∂η/∂t = −H ∇·𝐮
        div: Float[Array, "H W"] = divergence(u, v, grid)
        eta_next: Float[Array, "H W"] = eta - dt * depth * div
        # ∇η = (∂η/∂y, ∂η/∂x)
        gy: Float[Array, "H W"]
        gx: Float[Array, "H W"]
        gy, gx = gradient(eta_next, grid)
        # ∂𝐮/∂t = −g ∇η, g = 9.81 m s⁻²
        u_next: Float[Array, "H W"] = u - dt * G * gx
        v_next: Float[Array, "H W"] = v - dt * G * gy
        return (eta_next, u_next, v_next), eta_next

    # one trace, no carrier inside the loop: η after each of the S = n_total steps, (S, H, W)
    etas: Float[Array, "S H W"]
    _, etas = jax.lax.scan(step, state0, None, length=n_total)
    # index n is the state after n steps, so η₀ goes first: (S + 1, H, W)
    first: Float[Array, "1 H W"] = eta_init[None]
    traj: Float[Array, "S+1 H W"] = jnp.concatenate([first, etas])
    # the frames at the requested times: (T, H, W)
    frames: Float[Array, "T H W"] = traj[n_out]
    # exit: arrays → a series on eta0's grid, stamped with the requested times;
    # TimeAxis stamps are int64 nanoseconds, so offsets in s become ns first
    offsets_ns: Int[np.ndarray, "T"] = times.astype(np.int64) * 1_000_000_000
    stamps: Int[np.ndarray, "T"] = eta0.time + offsets_ns
    return GeoImageSeries.from_frames(frames, like=eta0, time=stamps)


# vardax/geo/assimilate.py — 4D-Var over a window of along-track SLA.
# vardax is GeoML and may not import GeoModels, so the forward model M is an argument.
# M: an initial map, output times and the ocean mask → the trajectory at those times
ForwardModel = Callable[
    [GeoMap, Int[np.ndarray, "T"], Bool[Array, "H W"]], GeoImageSeries
]


def assimilate(
    background: GeoMap,  # xᵇ, GeoArray["y x"]
    obs: GeoTracks,  # y, GeoPoints["obs"] padded to N, with a valid mask
    times: Int[np.ndarray, "T"],  # the window's output times, s after xᵇ's time
    forward: ForwardModel,  # M, from the caller
    sigma_b: float,  # background error std, m
    sigma_o: float,  # observation error std, m
) -> GeoImageSeries:

    # m_b = 1 where xᵇ is valid (ocean); land and nodata hold 0 and never enter J
    valid_b: Bool[Array, "H W"] = background.valid
    xb: Float[Array, "H W"] = jnp.where(valid_b, background.data, 0.0)

    def cost(x0: Float[Array, "H W"]) -> Float[Array, ""]:
        # m_b ⊙ x₀: land cells are held at 0, so their gradient is 0 and they never move
        x0_ocean: Float[Array, "H W"] = jnp.where(valid_b, x0, 0.0)
        # M(x₀) on the background's domain: the mask is passed, not re-derived from data
        x0_map: GeoMap = background.with_data(x0_ocean)
        traj: GeoImageSeries = forward(x0_map, times, valid_b)
        # H(M(x₀)): sampled at the observations in space and time
        predicted: GeoTracks = geoarray.sample(traj, obs)
        # d = m ⊙ (y − H(M(x₀))); padded slots give 0, never NaN
        resid: Float[Array, "N"] = obs.data - predicted.data
        d: Float[Array, "N"] = jnp.where(obs.valid, resid, 0.0)
        # m_b ⊙ (x₀ − xᵇ): finite everywhere, zero off the valid cells
        dx_raw: Float[Array, "H W"] = x0_ocean - xb
        dx: Float[Array, "H W"] = jnp.where(valid_b, dx_raw, 0.0)
        # J = ½‖m_b ⊙ (x₀ − xᵇ)‖²/σ_b² + ½‖d‖²/σ_o²
        jb: Float[Array, ""] = 0.5 * einx.dot("h w, h w ->", dx, dx) / sigma_b**2
        jo: Float[Array, ""] = 0.5 * einx.dot("n, n ->", d, d) / sigma_o**2
        return jb + jo

    # xₐ = argmin J, by gradient descent on ∇J
    cost_jit: Callable = jax.jit(cost)
    x_a: Float[Array, "H W"] = minimize(cost_jit, xb)
    # the analysis trajectory, as a readable series
    analysis_map: GeoMap = background.with_data(x_a)
    traj_a: GeoImageSeries = forward(analysis_map, times, valid_b)

    # land back to NaN in the output (broadcast over time), so its validity matches xᵇ
    def restore_land(frames: Float[Array, "T H W"]) -> Float[Array, "T H W"]:
        return jnp.where(valid_b, frames, jnp.nan)

    return traj_a.map(restore_land)


# the caller binds the model, in GeoModels or a script: M(x₀) = simulate(x₀, times; H, Δt)
forward: ForwardModel = functools.partial(somax.geo.simulate, depth=4_000.0, dt=600.0)
# daily output over a 7-day window, t = 0 included: (8,)
times: Int[np.ndarray, "T"] = np.arange(0, 8 * 86_400, 86_400)
series: GeoImageSeries = vardax.geo.assimilate(
    background, obs, times, forward, sigma_b=0.05, sigma_o=0.03
)

vardax only knows M through forward, so the same assimilator runs any model the caller binds, and the dependency points down. simulate turns requested output times into step counts before tracing, so a daily output with a 600 s step takes 144 steps per frame, and t = 0 returns the initial state. Inside cost the series is built and unwrapped once per evaluation. Whether that is cheap enough under grad, or cost should call the L1 loop directly, is open (OP-O8 in the open questions).

5Where each operation is written, once

OperationWritten inRuns on
L1 kernelits L1 package (radtrax, somax, …), or _src/array.py in geoops (observation, sensor, diagnostic geometry) or xrops (state physics)raw arrays
dim-level function (reduce, stack, map_over, …)pipekit-arrayevery registered carrier
KernelOp, observation and sensor domain (indices, radiometry, qa, normalize, spectral)geoopsboth columns
KernelOp, physical and state domain (atmosphere, ocean, budgets, metrics)xropsboth columns; needs xarray installed
per-item geometry (reproject, rasterize, sample, windows, patches, gradient)geoopsgeoarray carriers; cubes through xrops.lift
many-item geometry (lazy regrid of a cube, mosaics of many files)xropsxarray carriers
value analysis (resample, groupby, climatology, alignment)native xarray; xrops builds on itxarray carriers

The split of KernelOps by domain decides who maintains them, not where they run.

Dependency direction

pipekit
   │
   ▼
pipekit-array ── [xarray] adapter ──▶ xarray
   │                                  ▲
   ▼                                  │
geoarray                              │
   │                                  │
   ▼                                  │
geoops   (never imports xarray        │
   │      or xrops)                   │
   ▼                                  │
xrops ─────────── imports ─────────────┘
   reuses geoops directly and through xrops.lift

Lifting a geometry operator over a cube

# a per-item operator, written once for geoarray carriers: GeoArray["band y x"] → GeoArray["y x"]
detect: Operator = geoops.plume.DetectPlumes(threshold=tau)

# lifted over time by xrops: each slice becomes a GeoImage, runs, and is wrapped back
per_date: Operator = xrops.lift(detect, dim="time")

# lazy, one task per dask chunk: cube is DataArray["time band y x"]
masks: DataArray["time y x"] = per_date(cube)

Under the hood lift is xr.apply_ufunc with the spatial dims as core dims; each chunk is turned into a carrier with GeoArray.from_xarray, so the georeference comes from the cube’s grid mapping.

6geoops

geoops holds three kinds of code: L1 kernels in its array.py modules, observation-domain KernelOps that run on both columns, and per-item geometry operators that run on geoarray carriers. Today it has 218 operator classes; 61 store a positional self.axis, and 106 kernel signatures default to axis=-3.

Three operator shapes

ShapeBaseBackendsExample
kernel operatorKernelOpnumpy, jax; xarray through the adapterNDVI(dim="band")
geometry operator, array mathOperator, derives geometry from the georeference, then calls a primitivenumpy, jaxSample, Window, ShiftOrigin, Reproject (pyproj in the operator, map_coordinates primitive)
geometry operator, engine-boundOperator, calls GDAL or shapely; no primitivenumpyRasterize, Vectorize
# L1 kernel in geoops/indices/_src/array.py: "(),()->()"
def normalized_difference(
    a: Float[Array, "*s"], b: Float[Array, "*s"], eps: float
) -> Float[Array, "*s"]:
    # ND = (a − b) / (a + b + ε)
    return (a - b) / (a + b + eps)


# L2 operator: two labels of one dim, then the kernel
class NDVI(Operator):
    backends: tuple[str, ...] = ("numpy", "jax", "xarray")

    def __init__(
        self, nir: str = "B8", red: str = "B4", dim: str = "band", eps: float = 1e-10
    ) -> None:
        self.nir: str = nir
        self.red: str = red
        self.dim: str = dim
        self.eps: float = eps

    def _apply(self, scene: GeoArray["band y x"]) -> GeoArray["y x"]:
        # exact label lookups by name, any carrier
        nir_sel: dict[str, str] = {self.dim: self.nir}
        red_sel: dict[str, str] = {self.dim: self.red}
        nir: GeoArray["y x"] = pipekit_array.sel(scene, **nir_sel)
        red: GeoArray["y x"] = pipekit_array.sel(scene, **red_sel)
        # the kernel, element-wise, through KernelOp
        nd: KernelOp = KernelOp(
            normalized_difference, "(),()->()", core={}, eps=self.eps
        )
        # NDVI = (ρ_NIR − ρ_red) / (ρ_NIR + ρ_red + ε)
        return nd(nir, red)
# L2 geometry operator, array math: pyproj in the operator, a primitive for the resampling
class Reproject(Operator):
    backends: tuple[str, ...] = ("numpy", "jax")

    def __init__(self, crs: str, resolution: float) -> None:
        self.crs: str = crs
        self.resolution: float = resolution

    def _apply(self, scene: GeoArray["band y x"]) -> GeoArray["band y x"]:
        # the target grid: transform and shape in the new CRS
        target: GridSpec = geoarray.target_grid(
            scene, crs=self.crs, resolution=self.resolution
        )
        # derived geometry: source pixel coordinates (r, c) of every target pixel, by pyproj
        rows: Float[Array, "H W"]
        cols: Float[Array, "H W"]
        rows, cols = geoarray.source_coords(scene, target)
        # L1: q(y′, x′) = ∑ wᵢ q(rᵢ, cᵢ), bilinear weights wᵢ, per band
        warped: Float[Array, "C H W"] = map_coordinates(scene.data, rows, cols, order=1)
        # rewrap on the target grid, same dims
        return GeoArray.from_grid(warped, target, dims=scene.dims)

Families

FamilyShapeBackends
indices, qakernel operatorsnumpy, jax, xarray
normalizekernel operators; skimage-backed ones engine-boundnumpy, jax, xarray / numpy
compositingthin named operators over stack, reduce, argreduce, take_alongnumpy, jax, xarray
einxkernel operators over einx patterns (see OP-O2)numpy, jax, xarray
augmentkernel operators for flips, crops, noise (PRNG key under JAX); scipy warps engine-boundnumpy, jax / numpy
spectralkernel operators backed by scipynumpy, xarray
learnscikit-learn wrappersnumpy
geom, io, vizgeometry, engine-boundnumpy
mask, measure, feature, segment, restore, radiometrymixed: vector and solar-geometry parts engine-bound, array parts kernel operatorsper op
matched_filter, plumeleaving for radtrax and plumax (#482); rebuilt there as kernel operators—

Rules

7xrops

xrops is the xarray column: value analysis built on native xarray operations, many-item geometry, and the physical- and state-domain KernelOps, which also run on geoarray carriers. It is the code of today’s xrtoolz distribution, renamed per the package layout page, with xrcore’s operator base folded into xrops._src. It sits one tier above geoops: it imports geoops for every kernel and geometry operator it does not own, and never re-implements one.

What it writes and what it reuses

xrops writesxrops reuses
state-domain kernel operators (atmosphere, ocean, budgets, metrics, spectral transforms)KernelOp, the dim-level functions (pipekit-array)
value analysis: climatologies, anomalies, detrending, regimes, extremesgroupby, resample, rolling, sel by value (xarray)
many-item geometry: lazy regrid of a cube, mosaics of many files, masks by regionapply_ufunc, dask (xarray)
Dataset and DataTree dispatch: run an operator per variable or per nodepipekit.Signature, Operator

A state-domain kernel operator on both columns

# L1 kernel in xrops/ocn/_src/array.py: consumes the level axis, "(l),(l)->()"
def mixed_layer_depth(
    sigma0: Float[Array, "... L"],  # σ₀, kg m⁻³, L levels last
    depth: Float[Array, "... L"],  # z, m
    threshold: float,  # kg m⁻³
) -> Float[Array, "..."]:
    # numpy or jax.numpy, chosen by the input
    xp: ModuleType = array_namespace(sigma0)
    # σ₀(z_ref), z_ref the shallowest level
    ref: Float[Array, "... 1"] = sigma0[..., :1]
    # Δσ(z) = σ₀(z) − σ₀(z_ref)
    delta: Float[Array, "... L"] = sigma0 - ref
    # 𝟙[Δσ(z) > threshold]
    below: Bool[Array, "... L"] = delta > threshold
    # the first level where Δσ exceeds the threshold
    first: Int[Array, "..."] = xp.argmax(below, axis=-1)
    first_l: Int[Array, "... 1"] = first[..., None]
    # MLD = z at that level
    picked: Float[Array, "... 1"] = xp.take_along_axis(depth, first_l, axis=-1)
    mld: Float[Array, "..."] = picked[..., 0]
    # argmax of an all-False row is 0: no crossing means no MLD, so NaN
    crossed: Bool[Array, "..."] = einx.any("... [l]", below)
    return xp.where(crossed, mld, xp.nan)


# L2: the level dim named, the threshold bound
mld_op: KernelOp = KernelOp(
    mixed_layer_depth, "(l),(l)->()", core={"l": "level"}, threshold=0.03
)

# a GLORYS-like cube, lazy: (time, level, y, x) → (time, y, x)
mld_cube: DataArray["time y x"] = mld_op(sigma0_cube, depth_cube)

# one gridded profile on a geoarray carrier: GeoVolume (level, y, x) → GeoMap (y, x)
mld_map: GeoArray["y x"] = mld_op(sigma0_volume, depth_volume)

σ₀ is potential density anomaly (kg m⁻³), z depth (m), and the threshold 0.03 kg m⁻³ a common MLD criterion.

A profile that never crosses the threshold (a well-mixed column, or one too shallow) returns NaN rather than the shallowest depth; a caller that wants the bottom depth as a fallback fills the NaNs explicitly.

Geometry written once at L1, wrapped per column

A gradient needs grid spacing, which is geometry. Decided 22 Oct 2026 (OP-O7): the diagnostic kernel lives in geoops and takes spacings as arrays; each column only derives the spacings.

# geoops/geom/_src/array.py — L1, spacings as arrays
def central_gradient(
    f: Float[Array, "... H W"],
    dy: Float[Array, "H W"],
    dx: Float[Array, "H W"],
) -> Float[Array, "2 ... H W"]:
    # numpy or jax.numpy, chosen by the input
    xp: ModuleType = array_namespace(f)
    # neighbours along y and x
    f_n: Float[Array, "... H W"] = xp.roll(f, -1, -2)
    f_s: Float[Array, "... H W"] = xp.roll(f, 1, -2)
    f_e: Float[Array, "... H W"] = xp.roll(f, -1, -1)
    f_w: Float[Array, "... H W"] = xp.roll(f, 1, -1)
    # ∂f/∂y ≈ (fᵢ₊₁ − fᵢ₋₁) / 2Δy
    fy: Float[Array, "... H W"] = (f_n - f_s) / (2 * dy)
    # ∂f/∂x ≈ (fⱼ₊₁ − fⱼ₋₁) / 2Δx
    fx: Float[Array, "... H W"] = (f_e - f_w) / (2 * dx)
    # (∂f/∂y, ∂f/∂x) on a new leading axis
    return einx.rearrange("... h w, ... h w -> (1 + 1) ... h w", fy, fx)


# geoops: spacing from the transform (a, e), or geodesic distances for a geographic CRS
grad_op: Operator = geoops.geom.Gradient()
grad_map: GeoArray["component y x"] = grad_op(ssh_map)  # ssh_map: GeoArray["y x"]

# xrops: spacing from coordinate values, spherical metrics on lat/lon; the same geoops kernel
grad_xr: Operator = xrops.Gradient(dims=("lat", "lon"))
grad_cube: DataArray["component time lat lon"] = grad_xr(
    ssh
)  # ssh: DataArray["time lat lon"]

Edges are elided here (roll wraps); the real kernel handles them.

ColumnWrapperLives inSpacing from
geoarraygeoops.geom.Gradientgeoopsthe transform (a, e), or geodesic distances for a geographic CRS
xarrayxrops.Gradient (today’s xrgrad)xrops, importing the geoops kernelcoordinate values, with spherical metrics on lat/lon

Two thin wrappers, one kernel. The tiering makes this possible: xrops imports the geoops kernel, so pipekit-array keeps no domain kernels. GeoStack and GeoNumerics still never import each other, which fixes where model-grade calculus goes:

Families

Family (today in xrtoolz)Becomes
atm, ocn, budgetsstate-domain kernel operators + value analysis
metricskernel operators over named dims; skill scores by value
transforms (Fourier, wavelet, DCT)kernel operators over named dims
interpolatenative interp plus many-item geometry
geosplit: geometry (crs, mosaic, rasterize, footprint) → many-item geometry; analysis (detrend, extremes, regimes) → value analysis
einx (xreinx), sklearn (xrsklearn)see OP-O2 and OP-O3
rs, iceremoved (empty stubs; observation indices live in geoops and run on cubes as they are)

8Testing

TestLives inProves
operator contractgeoops (today’s test_operator_contract.py), xropsconfig round trip, graph mode, declared backends, BackendError on the rest
check_kernel_oppipekit-array test kit, run by both packageseach kernel operator gives equal results on a geoarray carrier, a JAX carrier, an xarray cube and raw arrays
lift parityxropsxrops.lift(op, dim="time") on a cube equals the op on each slice, eagerly
tiersgeostack tests/test_dependency_graph.pygeoops imports neither xrops nor xarray; xrops is tier 2 above geotoolz
slim basegeotoolz CIa fresh pip install geotoolz-ops installs none of rasterio, geopandas, shapely, scikit-image, georeader; import geoops works
one tracegeoops, xropskernel operators compile once under jit for same-shape carriers
summarybothSequential.summary prints dims for every step, no ?
lazinessxropsoperators on dask-backed cubes compute nothing until asked

9Migration

1 pipekit, pipekit-array: adapter, Signature, functions, KernelOp
        │
        ▼
2 geoarray: adapter (geoarray page)
        │
        ▼
3 geoops: slim base install (blocks 5)
  axis → dim, KernelOps, backends,
  gradient kernel, no [xarray] extra
        │
        ▼
4 dependency graph: xrtoolz to tier 2
        │
        ▼
5 xrops (package layout page): rename, fold xrcore,
  Signature re-export, import geoops kernels,
  xrops.lift, xrops.Gradient, state-physics KernelOps

Each step is a 0.X.0 bump. xrops now follows geoops instead of moving in parallel: it imports geoops’s kernels, so step 5 waits for steps 3 and 4.

10Open questions

IDQuestionRecommendedAlternative
OP-O1Where kernel operators liveDecided 22 Oct 2026: observation and sensor kernels in geoops; state-domain kernels (MLD, budgets) in xrops; xrops imports geoops kernels and never re-implements onea third, neutral package of kernel operators
OP-O2Two einx bridges (geoops.einx, xreinx)one named-dims einx bridge in pipekit-array ([einx] extra); geoops keeps only its spatial-survival rulekeep both
OP-O3Two scikit-learn bridges (geoops.learn, xrsklearn)to_rows / from_rows as dim-level functions in pipekit-array, one estimator wrapper on pipekit’s FittableTransformerkeep both
OP-O4Lifting geometry over cubesDecided 22 Oct 2026: xrops.lift(op, dim=...) in xrops; geoops has no [xarray] extrageoops.lift behind geoops[xarray] (not taken)
OP-O5How operators use KernelOpby composition: an operator keeps its own config and calls a KernelOp inside (as NDVI does)every kernel operator subclasses KernelOp
OP-O6The backends vocabulary"numpy", "jax", "xarray"backends and carrier families declared separately
OP-O7Where grid calculus livesDecided 22 Oct 2026: the diagnostic gradient kernel in geoops (geoops/geom/_src/array.py), wrapped by geoops.geom.Gradient and xrops.Gradient (today’s xrgrad); pipekit-array keeps no domain kernels; model-grade calculus in finitevolX, wrapped by the GeoML or GeoModels package that uses itkernel in pipekit-array (not taken)
OP-O8Does an L3 algorithm call L3 algorithms inside a differentiated cost (the assimilator calling simulate)?yes, while wrap and unwrap stay cheap under grad; measured in the spikethe cost calls the L1 loop directly, and only the outer call returns a GeoImageSeries