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.

geoarray: the carrier

Design · one thin carrier family for NumPy and JAX: GeoArray, GeoSwath and GeoPoints, with stored dims, standard coordinates, readable types and the geometry that never changes values

1Decision

geoarray is the geometry vocabulary of the stack. It is one bottom package of geotoolz, geotoolz-array (import geoarray), holding one family of thin carriers whose data is a NumPy or a JAX array:

Each carrier stores its dims, carries standard coordinates and a geometry leaf, and is registered as a JAX pytree. geoarray also ships readable type names for the common shapes (GeoImage, GeoMap, GeoImageSeries, GeoTracks, …) and every operation that re-indexes or re-describes pixels without changing their values.

It replaces georeader.GeoTensor as the type that flows between geotoolz packages; georeader becomes an optional reader adapter. pipekit’s Operator contract does not change. The carrier rules in geotoolz’s AGENTS.md keep their meaning (fresh attrs, NaN-aware nodata, exact grid matching), except that dims are stored instead of inferred from ndim.

Where the carrier sits in the three levels: L2 operators and L3 algorithms take carriers or readable types; L1 primitives never see one.

Carriers and operations

A carrier is a data structure: values plus their description. There are two families, and xarray’s data structures are carriers just as much as geoarray’s.

FamilyCarriersHoldsGood at
geoarrayGeoArray, GeoSwath, GeoPointsNumPy or JAX data, dims, static coordinates, a geometry leaf (transform, lat/lon, xy), CRSone item or a fixed batch: strict grids, compiles under jit, about 1–10 µs per op
xarrayDataArray, Dataset, DataTreeNumPy or dask data, dims, coordinate values, attributes; Dataset groups variables on shared dims, DataTree nests groupsmany items: selection by value, laziness, out-of-core, variables with different dims

geoarray has no Dataset or DataTree equivalent on purpose: a carrier holds one stack of values (several bands or variables share one band or variable dim). Collections of differently shaped variables are xarray’s job, and the crossing is the labeled lane.

This page covers the data. How operations are written once and run on both families is in KernelOp; what geoops and xrops each own, and the tiers they sit in, is in operators.

Ownership rules

Carriers flow down from geoarray; nothing in it reads files, and nothing in it changes pixel values.

numpy · pyproj · affine · array-api-compat · pipekit-array
   ┊ extras: [jax] [xarray] [georeader] [coordax]
   ▼
geotoolz-array (import geoarray)
   carriers   GeoArray, GeoSwath, GeoPoints
   values     Grid, Window, FrozenAttrs, coords, types
   geometry   isel, crop, windows, valid_mask
   ▼
geocatalog · geocloud · geoproducts   native-grid reads → carriers
geopatcher                            plan on the host, crop
geoops                                KernelOps and geometry operators
   ▼
geotoolz (umbrella, no code)
   ┊ behind [geo] extras
   ▼
radtrax[geo] · plumax[geo] · esmax-core[geo]   downstream carriers
  1. geoarray owns the carriers, Grid, Window, FrozenAttrs, the coordinates, the readable types, its named-array adapter, and every function that re-indexes or re-describes pixels. It never imports georeader, rasterio or coordax in its base modules, and imports JAX and xarray only behind their extras.

  2. geoops owns every function that changes values or moves pixels to a new grid, as L1 kernels wrapped as KernelOps or as hand-written geometry operators (operators). It never builds a carrier by hand; it rewraps through the adapter.

  3. geocatalog, geocloud and geoproducts read bytes into a NumPy carrier on the file’s native grid, with its dims named. They never reproject, since that would make them import geoops.

  4. geopatcher plans patch offsets on the host and extracts them with geoarray.crop. It stops importing georeader.window_utils and georeader.read.

  5. georeader appears only behind extras: geoarray[georeader] for the carrier adapter, and the readers’ own extras for file access.

What the other packages change, in one table:

PackageTakes or makesWhat changes
geocatalog, geocloud, geoproductsacquisitions as geoarray carriers, cubes as xarray carriersreaders name their dims; swath readers return GeoSwath; stay JAX-free and xarray-optional
geopatchergeoarray carriersthe batch is a GeoPatches carrier with a (patch, 6) transform; split and stitch stay NumPy; the silent jnp = np fallback goes
geoopsboth familiesdim= replaces axis=; each operator declares backends; no xarray in its base install (operators)

Indicative costs (not yet reproducible)

One exploratory run on a CPU sandbox compared a 4-op normalized difference and a window on a mock thin NumPy carrier and on xr.DataArray. No script or output from that run is committed, so these numbers motivate the design but are not results: gate GA-G22 replaces them with a committed benchmark and its output, and the claims below stand or fall with it.

ShapeND op, carrierND op, xarrayWindow, carrierWindow, xarray
2 × 64 × 6412 µs2,257 µs1.4 µs80 µs
2 × 256 × 256275 µs2,779 µs1.0 µs63 µs
2 × 2000 × 200048 ms52 ms2.4 µs96 µs

The carrier earns its place by what it refuses (mismatched grids, a transform drifting from its coordinates, ambiguous pixel edges) and by compiling, not by speed on large scenes.

2The carrier

Each carrier is a frozen dataclass that composes an array, never subclasses one, and registers as a JAX pytree when JAX is importable.

ObjectLives inNameKind
Carriersgeoarray._src.arrayGeoArray, GeoSwath, GeoPointsfrozen dataclasses, pytrees
Pixel gridgeoarray._src.gridGridfrozen dataclass, hashable
Pixel windowgeoarray._src.gridWindowfrozen dataclass of ints
Coordinatesgeoarray.coordsPixelGrid, SpectralAxis, TimeAxis, …frozen dataclasses, hashable
Metadatageoarray._src.attrsFrozenAttrshashable Mapping
Backendgeoarray._src.backendxp, to_jax, to_numpyfunctions

Every field is either a leaf, which JAX traces, or static, which JAX hashes into the compiled function’s cache key. Changing a static field recompiles; changing a leaf does not.

FieldTypePytree roleChanging it
datanp.ndarray or jax.Array, ending in the spatial dimsleaffree (same shape and dtype)
transform(*batch, 6) float64, (a, b, c, d, e, f), per frame or per patchleaffree; differentiable
dimstuple[str, ...], e.g. ("time", "band", "y", "x")staticrecompiles
coordsone geoarray.coords coordinate per non-spatial dimstaticrecompiles
crspyproj.CRS, parsed once; strings and EPSG ints are coercedstaticrecompiles
fill_valuefloat, int, bool or Nonestaticrecompiles
attrsFrozenAttrsstaticrecompiles

GeoSwath replaces transform with lat and lon leaves (…, row, col); GeoPoints replaces it with an xy leaf (obs, 2) and optional z, time and valid leaves (obs,).

# composition, frozen; pytree registration is by hand (see "Construction" below)
@dataclasses.dataclass(frozen=True, eq=False)
class GeoArray:
    # np.ndarray or jax.Array, ending in (y, x)
    data: Float[Array, "... H W"]
    # T = (a, b, c, d, e, f): (x, y) = (a·col + b·row + c, d·col + e·row + f), per frame or per patch
    transform: Float[Array, "*batch 6"]
    # ("time", "band", "y", "x"); static
    dims: tuple[str, ...]
    # one geoarray.coords coordinate per non-spatial dim; static
    coords: tuple[Coordinate, ...]
    # fixed for a compiled program
    crs: pyproj.CRS
    # nodata marker; NaN allowed; None means no fill
    fill_value: float | int | bool | None = None
    # hashable non-coordinate metadata: sensor, product, processing level
    attrs: FrozenAttrs = dataclasses.field(default_factory=FrozenAttrs)

Static or leaf. What fixes a compiled program (dims, sizes, band and level labels, CRS, fill value) is static. Geometry that changes per call (the transform, swath lat/lon, point xy and z, time stamps) is a leaf, so a new scene, patch or day of observations never recompiles. Shapes are static too: pad point sets to a fixed N with a valid mask; geopatcher already fixes the patch size.

The backend is the data’s type. An operator given one NumPy and one JAX carrier refuses and names to_jax(); nothing is promoted silently.

Compose, do not subclass (GA-A1)

JAX operations always return a plain jax.Array, so a subclass would lose its metadata on the first jnp call. Composition costs one thing: g + 1 no longer works (GA-B1). In return, one class holds both backends, and eq=False avoids the ambiguous truth value that elementwise == would give a dataclass. Equality of carriers is g.identical(h), and equality of grids is g.grid == h.grid.

The transform is a float64 leaf (GA-A2)

A leaf transform lets jax.grad reach the georeferencing (Stage 4 of the running example). It also lets vmap batch patches whose origins differ, and lets jit reuse one compiled function across scenes. The prototype confirmed both: a new transform did not retrace, while a new CRS or shape did.

float64 is required because map coordinates are large. In float32 the spacing between representable numbers is 0.25 m at a northing of 3.55 × 10⁶ m, 0.5 m at 4.65 × 10⁶ m, and 7.6 × 10⁻⁶° (≈ 0.7 m at 32° N) at longitude −103.75°. So to_jax checks jax.config.jax_enable_x64 and raises with a message if it is off, rather than letting JAX silently round the transform to float32. data keeps its own dtype, normally float32.

Recommended: require x64 for JAX carriers. Alternative: store a static float64 origin (c₀, f₀) and a float32 leaf offset; no global flag, but every new origin recompiles.

Static, frozen attrs (GA-A3)

Per-band metadata (names, wavelengths, FWHM) moved into a SpectralAxis in coords (standard coordinates), so attrs holds only what describes the whole carrier: sensor, product, processing level. Static attrs must be hashable, so FrozenAttrs enforces three rules:

  1. Lists become tuples. ["L1C", "B"] is stored as ("L1C", "B").

  2. 1-D numeric arrays become tuples of Python floats. Building and hashing EMIT’s 285 wavelengths this way took about 21 µs in the prototype.

  3. Anything else unhashable raises. That covers dicts, 2-D arrays and live objects such as open file handles.

Updates return a new carrier: g.with_attrs(sensor="EMIT") and g.drop_attrs("id"). Scene-specific keys such as acquisition time or scene ID would recompile per scene, so a pipeline strips non-geometric keys before jit and reattaches them after; acquisition times belong in a TimeAxis leaf instead.

No fill by default (GA-A4)

fill_value=None means “no nodata marker”; valid_mask then tests finiteness only. Readers set the fill explicitly: 0 for Sentinel-2 L1C digital numbers, NaN after conversion to reflectance. The georeader adapter carries georeader’s value across unchanged, so converting an existing GeoTensor changes nothing.

Construction and unflattening

__post_init__ validates: dims ending in the spatial dims of the carrier kind, data whose rank matches dims, a transform of shape (*batch, 6), one coordinate per non-spatial dim with the right size, a parseable CRS, and attrs that freeze. The pytree unflatten bypasses __post_init__, because JAX unflattens with placeholder leaves during tracing, and a shape check would fail on them. That is why the registration is written by hand rather than with jax.tree_util.register_dataclass, which calls the constructor. The pytree is registered at import time inside a try: import jax, so a NumPy-only install never imports JAX.

A bare jax.vmap over a carrier batches its leaves but leaves the static dims unchanged, so it would describe a batched array with the wrong dims. Batching goes through geoarray.map_over or a function that adds the dim (geopatcher.split, Stage 5).

The Grid value type

Grid is the transform, CRS and spatial shape without data. It is hashable, so it can be a warp target, an operator’s static config or a dictionary key. geocatalog’s GeoSlice and geopatcher’s domains both produce one, so there is one “where are these pixels” object across the stack.

# no data, so it is cheap, hashable and JSON-serialisable
@dataclasses.dataclass(frozen=True)
class Grid:
    # (a, b, c, d, e, f) as Python floats
    transform: tuple[float, float, float, float, float, float]
    crs: pyproj.CRS
    # (H, W)
    shape: tuple[int, int]

    # (|a|, |e|) for an axis-aligned grid
    @property
    def res(self) -> tuple[float, float]: ...

    # (x_min, y_min, x_max, y_max)
    @property
    def bounds(self) -> tuple[float, float, float, float]: ...

    # b = d = 0
    @property
    def is_axis_aligned(self) -> bool: ...

    # T⁻¹(bounds), rounded outward to whole pixels
    def window(self, bounds: tuple[float, float, float, float]) -> "Window": ...

    # exact equality is ==; this allows a tolerance in map units
    def isclose(self, other: "Grid", atol: float = 1e-6) -> bool: ...

    # strict JSON: CRS as its authority string or WKT
    def get_config(self) -> dict: ...

    @classmethod
    def from_bounds(cls, bounds, res, crs) -> "Grid": ...

3Named dims and geometry kinds

Dims are stored on the carrier. They replace the positional convention ("time", "band", "y", "x")[-ndim:], under which a 3-D array was always (band, y, x) and every operator took axis=-3:

# today: 12 monthly frames of one band, 600 × 600
data: Float[Array, "T H W"] = monthly_values
# dims inferred from ndim: ('band', 'y', 'x'), so 12 "bands", not 12 months
monthly: GeoArray = GeoArray(data, transform, "EPSG:32613")
# ops take a position, and every caller must know the layout
mf: MatchedFilter = geoops.matched_filter.MatchedFilter(target=ch4_target, axis=-3)

With stored dims the same frames are GeoArray["time y x"], and operators take dim="band".

A new class exists only when the georeferencing changes; everything else is a named dim.

CarrierGeoreference leafSpatial dims (last)Examples
GeoArraytransform (…, 6)y xSentinel-2 scenes, gridded SLA maps, a model grid
GeoSwathlat, lon (…, row, col)row colEMIT, TROPOMI, AVIRIS in sensor geometry
GeoPointsxy (obs, 2), optional z (obs,), optional valid (obs,) for paddingobsArgo cycles, along-track SSH, stations, plume sources

Other dims come before the spatial ones. Any name works; these are the conventions geoops and the converters expect:

DimMeansLabelsxarray name
timeacquisition or valid timedatetime64time
memberensemble memberstringsmember
levela vertical grid shared by every pixellayer index or nominal value, with a Verticallevel (maps CF depth, lev, plev, layer)
bandspectral bandnames, optional wavelength in nmband
variablephysical quantity with its own unitsnames and unitsvariable
patchpatches from geopatchernone—

Level or z? The test is whether samples share the vertical grid:

z is never a third column of xy: height (m, up), depth (m, down) and pressure (hPa) differ in units and direction, so z carries a Vertical (kind, units, CF positive, datum).

4Standard coordinates and readable types

geoarray ships the coordinates every geoscience array needs, so users never build LabeledAxis("band", …) by hand, and a set of readable type names for the common carrier shapes. Both follow the static-or-leaf rule: what is fixed for a compiled program lives on the coordinate; what varies per call stays a leaf on the carrier.

Coordinates (geoarray.coords)

CoordinateDimsStatic contentLeaf on the carrier
PixelGridy xshape, CRS, pixel convention (area or point)transform (…, 6)
SwathGridrow colshape, CRS; rows along track, columns acrosslat, lon (…, row, col)
CurvilinearGridy xshape, CRS, fixed lat and lon (a model grid such as ORCA)none
PointAxisobsN, CRSxy (obs, 2), optional z (obs,), optional time (obs,)
SpectralAxisbandnames, centre wavelength (nm), optional FWHM (nm)none
VariableAxisvariablenames and units of physical quantities (K, hPa, m s⁻¹)none
VerticalAxislevellevel values + a Vertical (kind, units, CF positive, datum)none; per-pixel pressures are data in a band
TimeAxistimesize only, under [jax]stamps as an int64 leaf (datetime64[ns])

band and variable are separate on purpose. Spectral channels share units and have wavelengths, so NDVI or a matched filter makes sense over them. Station or model variables carry different units, and an operator that mixes channels must refuse them.

Several variables per point. A weather station reporting temperature, pressure and wind is one point with a variable dim: GeoPoints["time variable obs"] for 40 stations reporting hourly. A variable a station does not report is the fill value, masked as usual.

Time as a dim or per sample. Shared stamps are a time dim with a TimeAxis: a stack of scenes, fixed stations reporting hourly. A moving platform has one time per sample, so time is an (obs,) leaf on GeoPoints, not a dim: along-track altimetry, Argo, ship tracks.

TimeAxis is the one that splits. Its stamps change from batch to batch, so under [jax] they are a leaf, never a static label; otherwise every new set of dates would recompile. On the base install they are ordinary labels.

Each coordinate is a frozen, hashable dataclass with dims and shape, static under jit. The converters write them as CF coordinates and attributes and read them back (labeled lane).

from geoarray.coords import PixelGrid, SpectralAxis, TimeAxis

# Sentinel-2 SWIR: names and centre wavelengths, nm
swir: SpectralAxis = SpectralAxis(names=("B11", "B12"), wavelength_nm=(1610.0, 2190.0))
# a 600 × 600 grid in UTM 13N, area-convention pixels
grid: PixelGrid = PixelGrid(shape=(600, 600), crs="EPSG:32613")
# 12 acquisition dates
dates: TimeAxis = TimeAxis(stamps=acquired)
# raw values: 12 dates, 2 bands, 600 × 600
data: Float[Array, "T C H W"] = swir_values
# T = (a, b, c, d, e, f): 20 m pixels, origin at (600 000, 3 550 000) m
T: tuple[float, ...] = (20.0, 0.0, 600000.0, 0.0, -20.0, 3550000.0)
# a carrier from coordinates, data and the transform leaf; a GeoImageSeries
stack: GeoArray["time band y x"] = GeoArray.from_coords(
    data, dates, swir, grid, transform=T
)

Readable types (geoarray.types)

The names are aliases for a carrier plus its dims, not subclasses. Slicing a GeoImageSeries at one date simply is a GeoImage, and check_dims verifies the dims at the call, on pipekit.Signature patterns.

TypeCarrier and dimsTypical data
GeoMapGeoArray["y x"]one band: a matched-filter score, a mask, an XCH₄ map
GeoImageGeoArray["band y x"]one multispectral scene
GeoImageSeriesGeoArray["time band y x"]frames over time, like a video: a Sentinel-2 stack
GeoVolumeGeoArray["level y x"]gridded profiles on shared levels (EN4)
GeoPatchesGeoArray["patch band y x"]a geopatcher batch
GeoSwathImageGeoSwath["band row col"]EMIT or AVIRIS L1B in sensor geometry
GeoSwathProfileGeoSwath["level row col"]TROPOMI averaging kernels and priors
GeoTimeSeriesGeoPoints["time variable obs"]fixed locations over shared times: weather stations, pixel series sampled from a stack
GeoTracksGeoPoints["variable obs"] + time leafmoving platforms: along-track altimetry, ship tracks
GeoProfilesGeoPoints["variable obs"] + z leafArgo, CTD, gliders

A time series is not a video. An image series is frames on a grid (time band y x). A time series is values at fixed locations (time variable obs), which is what “time series” means in geo usage. Sampling a GeoImageSeries at station points gives a GeoTimeSeries.

from geoarray.types import GeoImage, GeoMap


@check_dims
def ndvi(scene: GeoImage) -> GeoMap:
    # ρ_NIR and ρ_red, selected by band name
    nir: GeoArray["y x"] = scene.sel(band="B8")
    red: GeoArray["y x"] = scene.sel(band="B4")
    # NDVI = (ρ_NIR − ρ_red) / (ρ_NIR + ρ_red); grids checked, never aligned
    return combine(_normalized_difference, nir, red)


# one date of a series is an image, with no conversion; a GeoImage
first: GeoArray["band y x"] = stack.isel(time=0)
# map over time: a series of maps
series: GeoArray["time y x"] = geoarray.map_over(ndvi, stack, dim="time")

GeoArray was generic over its array type (GeoArray[np.ndarray]). The dims-string subscript replaces that use, since the backend now comes from the field.

5Geometry geoarray owns

geoarray owns every operation that re-indexes or re-describes pixels without computing new values, written once against the array namespace xp. The membership test: if the output contains only input values, fill values or coordinates, the function belongs here. Everything that computes new values (warping, resampling, CRS maths, padding by reflection) is a geoops operator: an L1 kernel wrapped as a KernelOp, or a hand-written geometry operator that derives source coordinates and calls a primitive (operators). geoarray holds none of them.

GeoTensor symbolDestinationReason
.dims (inferred from ndim).dims (stored)named dims
.transform (rasterio Affine).transform array; .grid.affine for an affine.Affineleaf for JAX, Affine on the host
__getitem__, iselisel(y=…, x=…), sel(band=…) by namecoordinates sliced with the data
read_from_window(window, boundless)geoarray.read_windowcrop with fill padding
write_from_window (in place)geoarray.write_window, returns a new carrierJAX arrays are immutable
pad with mode="constant"geoarray.padfills with fill_value
pad with other modes, resizegeoops kernelscompute new values
meshgrid() in the native CRSgeoarray.pixel_coordscoordinates only
meshgrid(dst_crs)a geoops geometry operatorneeds CRS maths
validmask, invalidmask.valid_maskNaN-aware rule from geoops._src.valid
same_extentg.grid == h.grid, Grid.iscloseexact by default, as geoops already requires
stack, concatenate, squeeze, expand_dims, transposeshared dim-level functions, dim=non-spatial dims only
footprint, valid_footprintgeoarray.footprint (NumPy only, shapely); valid_footprint in geoopsthe second’s output size depends on the data
window_from_bounds, round_outer_windowGrid.windowgeopatcher stops importing georeader

Arithmetic through map and combine (GA-B1)

GeoTensor supports gt + 1 and gt1 - gt2 through NumPy’s ufunc protocol, which JAX arrays do not offer to wrappers. geoarray replaces it with two explicit forms. g.map(f) applies f to the data and keeps the geometry, and raises if f changes the spatial shape. combine(f, g1, g2, …) checks that every grid is equal, then applies f to the data arrays. The grid check runs on concrete values; under jit the transforms are traced, so combine compares the static parts (dims, CRS and shape) and leaves transform equality to the caller.

# g ↦ log(1 + g), geometry unchanged
logged: GeoArray["band y x"] = s2_jax.map(jnp.log1p)
# the two bands as separate maps on one grid
b12: GeoArray["y x"] = s2_jax.sel(band="B12")
b11: GeoArray["y x"] = s2_jax.sel(band="B11")
# r = B12 / B11; raises if the grids differ
ratio: GeoArray["y x"] = geoarray.combine(jnp.divide, b12, b11)

Recommended: no dunders. Alternative: dunders that check grids eagerly and skip the transform check under jit.

Slicing moves the transform and the coordinates

Slicing rows i₀ : i₁ with step s_r and columns j₀ : j₁ with step s_c changes the transform by a shift and a scale:

a′=ascb′=bsrc′=c+aj0+bi0d′=dsce′=esrf′=f+dj0+ei0\begin{aligned} a' &= a s_c & b' &= b s_r & c' &= c + a j_0 + b i_0 \\ d' &= d s_c & e' &= e s_r & f' &= f + d j_0 + e i_0 \end{aligned}

Spatial dims take slices only, with positive steps; other dims take anything NumPy indexing allows. Selecting along a dim selects the matching entries of its coordinate: isel(band=slice(1, 2)) keeps band with a one-entry SpectralAxis, and sel(band="B12") drops the dim. g.shift_origin(δ) is the traced counterpart for a sub-pixel move: it adds δ to (c, f), and gradients flow through it.

Traced crops

Plain slicing needs concrete offsets. geoarray.crop(g, offset, size) takes a traced (row, col) offset and a static (h, w) size, uses lax.dynamic_slice on JAX data and slicing on NumPy data, and shifts the transform as above. lax.dynamic_slice clamps an out-of-range offset to stay inside the array instead of raising, so crop documents that and read_window handles out-of-bounds windows with padding.

# x_p = x[…, o_p : o_p + (h, w)], T_p = T shifted by o_p
def crop(
    g: GeoArray["... y x"],
    offset: Int[Array, "2"],
    size: tuple[int, int],
) -> GeoArray["... y x"]: ...

Windows

Window(row_off, col_off, height, width) is a frozen dataclass of Python ints, equivalent to a rasterio Window without importing rasterio. Grid.window(bounds) rounds outward, as round_outer_window does today. Grid.bounds_of(window) is its inverse on grid-aligned bounds. read_window(g, window, boundless=True) pads the outside with the fill value, and requires a fill value when the window leaves the array.

Shared dim-level functions

A short list of dim-level functions works on a geoarray carrier (NumPy or JAX) and on an xr.DataArray. They are written once, in pipekit-array, over a named-array adapter that each carrier family registers; geoarray registers its adapter and re-exports them, so geoarray.reduce and pipekit_array.reduce are the same object. The rule for adding one is on the pipekit kernels page: it never reads coordinate values or geometry and behaves identically on every carrier.

PieceWhere it lives
isel, sel (exact), transpose, reduce, argreduce, take_along, map_over, stack, concat, combinepipekit-array, re-exported by geoarray
the geoarray adapter: dims, data, labels, an isel that slices coordinates and moves the transform origin, a rewrap that keeps the georeference, a check_compatible that compares grids and raises GridMismatchError, map_over over data and transform rows togethergeoarray
geometry functions: sample (H(x)), windows that move the transform, to_rows / from_rows for pixels as rows, to_pixelgeoarray
type names and check_dims, built on pipekit.Signature patternsgeoarray (names), pipekit (rule)

The full operator surface is not shared, because the backends differ in four behaviours:

BehaviourCarrier, NumPyCarrier, JAXxarray
two inputs on different gridsrefusesrefusesinner-joins silently in plain arithmetic
lazinesseagertraced under jitoften dask-lazy; .data can trigger a full compute
compilesnoyesno
per-op overhead (indicative, GA-G22)about 1–10 µsabout 1–10 µs eagerabout 60–2,000 µs

On xarray, combine and stack use the xarray adapter’s exact comparison, so the silent-alignment trap does not apply to them; the alignment difference is one explicit check per carrier family instead of a hidden default.

Composites are two ops. A median composite is stack then reduce over time. A max-NDVI or cloud-free composite picks a time index per pixel from one quantity and gathers the other bands at it: argreduce then take_along. The maths is the same on every backend; the execution differs (eager in memory, or chunked with dask for 100 scenes of 10k × 10k).

6Backends and crossings

Every move between backends or libraries is an explicit call, and each one is a function in geoarray.

CallFrom → toNotes
g.to_jax(device=None)NumPy → JAXjax.device_put for data and the geometry leaves; raises if x64 is off (GA-A2)
g.to_numpy()JAX → NumPyhost copy of data and leaves
np.asarray(g)NumPy carrier → np.ndarraythe data, so np.asarray(x) in user code keeps working
np.asarray(g) on a JAX carrierJAX → np.ndarrayeager: a host copy; inside jit, grad or vmap: raises GeoArrayTraceError (GA-C1)
from_georeader(gt), to_georeader(g)GeoTensor ↔ GeoArraygeoarray[georeader]; NumPy only; values, transform, CRS, fill and attrs carried over, band names into a SpectralAxis
Grid.affine, Grid.from_affineaffine.Affine ↔ Gridaffine is a small BSD-licensed base dependency, already used by geoops
to_xarray, from_xarray, to_coordax, from_coordaxgeoarray ↔ xarray, coordaxbehind [xarray] and [coordax]; lossless for transform, dims and coordinates (labeled lane)

g.xp returns the array namespace through array_api_compat, so a function written against xp runs on both backends. g.backend returns "numpy" or "jax" for dispatch tables.

NumPy and JAX, eagerly and under transforms (GA-C1)

JAX and NumPy already convert to each other outside a transform: jnp functions accept NumPy arrays, and np.asarray copies a JAX array to the host. Inside jit, grad or vmap the data are tracers, which NumPy cannot read, so one check on the data catches all three transforms. The prototype confirmed it: an eager np.asarray on JAX data returned a NumPy array, and the same call inside each transform raised the custom error instead of JAX’s TracerArrayConversionError.

# eager → host copy; traced → a geoarray error
def __array__(self, dtype=None, copy=None) -> np.ndarray:
    # tracers exist only if JAX is loaded, so NumPy-only installs never import it
    jax_loaded: bool = "jax" in sys.modules
    if jax_loaded and _is_tracer(self.data):
        raise GeoArrayTraceError(
            "a NumPy function received GeoArray data inside jit/grad/vmap; "
            "use a JAX-capable operator, or call it outside the transform"
        )
    return np.asarray(self.data, dtype=dtype)

That host copy is for user code and notebooks. Operators do not rely on it: each declares its backends, and one given a carrier it cannot run refuses with a BackendError naming the fix (to_numpy(), or the JAX-capable operator). Mixed NumPy and JAX inputs refuse and name to_jax() rather than promoting.

What runs where, for geoarray’s own functions:

FunctionNumPyJAXUnder jit
properties, grid, bounds, resyesyesgrid needs a concrete transform
pixel_coords, valid_mask, map, combineyesyesyes
isel, selyesyesconcrete slices and labels only
cropyesyestraced offset, static size
read_window, write_window, padyesyesconcrete window
stack, concat, reduce, map_overyesyesyes
footprintyesnono (shapely)

When to jit

JAX is for the compiled step: the model, the loss, its gradient and the optimizer, run many times on fixed shapes. Pre-processing before it and post-processing after it stay NumPy and eager, run once per file. The carrier crosses the boundary twice, with to_jax() and to_numpy(); the run modes themselves are defined in the vision.

PRE · NumPy, eager, once per file
read COG / NetCDF · reproject · mask clouds
rasterize polygons · split into patches
(geoops NumPy operators, geopatcher, geocatalog)
        │
        │ to_jax()      fixed shapes from here
        ▼
COMPILED · JAX, jit + grad
normalize · augment · model forward
H(x) · averaging kernel · loss · ∇ · optimizer step
(GeoML, GeoModels, dual-backend geoops)
        │
        │ to_numpy()
        ▼
POST · NumPy, eager, once per result
threshold · vectorize plumes · stitch patches
metrics · write COG / Zarr · plots
(geoops NumPy operators, geopatcher, xrops)

The test for any operator: put it inside jit when it runs many times on the same shapes, a gradient must flow through it, and it is array maths (elementwise, einx, linear algebra); keep it NumPy when it runs once per file, its shapes depend on the data (vectorized polygons, variable-length tracks), or it calls GDAL, rasterio, georeader, SciPy, scikit-image or scikit-learn.

Some operators sit on both sides. normalize runs in pre for a one-off product and inside the training step when its statistics are learned; that is why a handful of geoops operators are dual-backend. The ones that pass the test and call no CPU-only library run on JAX: indices, QA, array-maths normalize, compositing (as reduce / argreduce + take_along), basic augmentation. matched_filter and plume move to radtrax and plumax (geotoolz #482).

7Running example: one methane plume, two sensors

The problem: a methane plume over a well pad in the Delaware Basin, seen by EMIT and by Sentinel-2 within the same week. EMIT gives the spectral detection, and Sentinel-2 gives a finer grid to confirm it on. The goal is a stack of co-registered 64 × 64 patches that a plume detector can score.

The data:

Sizes used below: C bands, H × W pixels, P patches, h × w patch size; T = (a, b, c, d, e, f) is the affine transform.

Stage 1: describe a chip (geocatalog → geoarray)

Let x∈RC×H×Wx \in \mathbb{R}^{C \times H \times W} be the chip, with C = 2, H = W = 600. Its grid is the triple (T, crs, (H, W)), where T maps pixel coordinates (column j, row i) to map coordinates. Pixel (i, j) covers a square whose centre is

(xijyij)=(abde)(j+1/2i+1/2)+(cf)\begin{pmatrix} x_{ij} \\ y_{ij} \end{pmatrix} = \begin{pmatrix} a & b \\ d & e \end{pmatrix} \begin{pmatrix} j + 1/2 \\ i + 1/2 \end{pmatrix} + \begin{pmatrix} c \\ f \end{pmatrix}

For a north-up grid b = d = 0, a is the pixel width and e is minus the pixel height. The window for a bounding box (x_min, y_min, x_max, y_max) is T⁻¹ applied to its corners, rounded outward to whole pixels.

import geoarray as ga
import geocatalog as gc

# a NumPy carrier on the tile's native grid, dims named by the reader
s2: GeoArray["band y x"] = gc.read(item, bounds=aoi, bands=("B11", "B12"))
# the band coordinate: names and centre wavelengths, nm
swir: SpectralAxis = s2.coord("band")
# the grid is a hashable value, (T, crs, (H, W))
grid: ga.Grid = s2.grid
# pixel centres: x_ij = a(j + ½) + b(i + ½) + c, y_ij = d(j + ½) + e(i + ½) + f
xy: Float[np.ndarray, "2 600 600"] = ga.pixel_coords(s2)
# (x_min, y_min, x_max, y_max) from T and (H, W)
bounds: tuple[float, float, float, float] = grid.bounds

GeoTensor gives the same properties, but its pixel_coords equivalent lives in georeader.griddata.meshgrid and goes through rasterio. geoarray keeps the maths and drops rasterio, so the same lines run on JAX data in Stage 4.

Stage 2: valid pixels and band selection (geoarray)

Let φ be the fill value, possibly NaN. A pixel (i, j) is valid when every band is finite and none equals the fill:

vij=⋀k=1C[isfinite(xkij)∧xkij≠ϕ]v_{ij} = \bigwedge_{k=1}^{C} \left[ \mathrm{isfinite}(x_{kij}) \wedge x_{kij} \neq \phi \right]

When φ is NaN the second term is already covered by the first. georeader’s validmask() computes values != fill, and NaN ≠ NaN is always true, so a NaN-filled raster reports every pixel valid. geoops already works around this in geoops._src.valid; the correct rule moves into the type.

# v_ij = ∧_k [isfinite(x_kij) ∧ x_kij ≠ φ], k over the band dim
valid: Bool[np.ndarray, "600 600"] = s2.valid_mask
# keep the band dim; the SpectralAxis is sliced with the data, so band names stay ("B12",)
b12: GeoArray["band y x"] = s2.isel(band=slice(1, 2))
# by label, dropping the band dim
b12_map: GeoArray["y x"] = s2.sel(band="B12")
# a window moves the origin: c′ = c + a·j₀ + b·i₀, f′ = f + d·j₀ + e·i₀
rows: slice = slice(150, 450)
cols: slice = slice(150, 450)
centre: GeoArray["band y x"] = s2.isel(y=rows, x=cols)

Under GeoTensor, isel passed attrs through unchanged and wrap_like dropped per-band keys later only if the band count changed; with the band names on a coordinate, there is nothing to keep in sync.

Stage 3: one grid for both sensors (geoarray → a geoops geometry operator)

Let z∈R2×Hs×Wsz \in \mathbb{R}^{2 \times H_s \times W_s} hold two EMIT layers on its EPSG:4326 grid (T_s, crs_s): band sim_B12 is EMIT radiance integrated over Sentinel-2’s B12 spectral response, and band dXCH4 is the matched-filter enhancement ΔX. The GLT step that produced z from the swath is a gather, not an affine map, and runs as a geoops operator before this stage. Let (T_d, crs_d, (H, W)) be the destination grid from Stage 1, and π the map from crs_d to crs_s. Each destination pixel (i, j) is pulled back to fractional source coordinates

uij=Ts−1(π(Td(j+1/2,i+1/2)))−(1/2,1/2)u_{ij} = T_s^{-1}\left( \pi\left( T_d (j + 1/2, i + 1/2) \right) \right) - (1/2, 1/2)

and the warped value is the bilinear blend of the four source pixels N(u_ij) around it, with weights wkl≥0w_{kl} \ge 0 that sum to one:

z~ij=∑(k,l)∈N(uij)wkl(uij)zkl\tilde{z}_{ij} = \sum_{(k, l) \in N(u_{ij})} w_{kl}(u_{ij}) z_{kl}
import geoops

# the two orthorectified EMIT layers; band = ("sim_B12", "dXCH4")
z: GeoArray["band y x"] = emit_layers
# move data and transform to the device; requires jax_enable_x64
s2_jax: GeoArray["band y x"] = s2.to_jax()
z_jax: GeoArray["band y x"] = z.to_jax()
# a geometry operator: u_ij from T_d, π and T_s⁻¹, then z̃_ij = ∑ w_kl z_kl
to_s2: Operator = geoops.geom.Warp(target=grid, order=1)
z_on_s2: GeoArray["band y x"] = to_s2(z_jax)

Warp is a hand-written geometry operator: it derives u from the two grids, with pyproj on NumPy data and a closed-form kernel on JAX data (EPSG:4326 to UTM here), then calls the L1 primitive map_coordinates (SciPy’s or jax.scipy.ndimage’s) on each band.

Stage 4: sub-pixel co-registration (leaf transform → jax.grad)

Let r∈RH×Wr \in \mathbb{R}^{H \times W} be Sentinel-2’s B12 on the Stage 1 grid, and m the warped simulated B12 from Stage 3. The two differ in calibration and resolution, so both are standardised to zero mean and unit variance; write r̃ and m̃. Let δ ∈ ℝ² shift the moving image’s origin, so T_m(δ) = (a, b, c + δ_x, d, e, f + δ_y). Let Ω be the interior pixels, 8 pixels away from the edges. The shift estimate minimises the squared misfit:

δ^=arg⁡min⁡δ∈R2∑(i,j)∈Ω(m~ij(δ)−r~ij)2,m~(δ)=std(W(m;Tm(δ)))\hat{\delta} = \arg\min_{\delta \in \mathbb{R}^2} \sum_{(i, j) \in \Omega} \left( \tilde{m}_{ij}(\delta) - \tilde{r}_{ij} \right)^2, \qquad \tilde{m}(\delta) = \mathrm{std}\left( \mathcal{W}(m; T_m(\delta)) \right)

Here 𝒲 is the Stage 3 warp, which is differentiable in δ because the transform is a JAX leaf, and std(·) subtracts the mean and divides by the standard deviation. The bilinear loss is piecewise quadratic in δ, with kinks at whole-pixel shifts, so start within one pixel of the answer. The same δ̂ then moves the enhancement ΔX.

import einx
import jax
import jax.numpy as jnp
import lineax as lx


def standardise(d: Float[Array, "H W"]) -> Float[Array, "H W"]:
    # μ = (1 / HW) ∑ᵢⱼ dᵢⱼ
    mu: Float[Array, ""] = einx.mean("h w ->", d)
    # d − μ
    centred: Float[Array, "H W"] = d - mu
    # s, the standard deviation of the centred field
    s: Float[Array, ""] = jnp.std(centred)
    # std(d) = (d − μ) / s
    return centred / s


# the interior Ω: an 8-pixel border dropped
inner: slice = slice(8, -8)
# r: Sentinel-2 B12 on the Stage 1 grid
b12_jax: GeoArray["y x"] = s2_jax.sel(band="B12")
# r on Ω
b12_inner: GeoArray["y x"] = b12_jax.isel(y=inner, x=inner)
# r̃ = std(r)
ref: Float[Array, "H W"] = standardise(b12_inner.data)
# m: the simulated B12 on the Sentinel-2 grid
sim_b12: GeoArray["y x"] = z_on_s2.sel(band="sim_B12")


def misfit(delta: Float[Array, "2"]) -> Float[Array, ""]:
    # T_m(δ) = (a, b, c + δx, d, e, f + δy); a float64 leaf, so ∂ misfit / ∂δ exists
    shifted: GeoArray["y x"] = sim_b12.shift_origin(delta)
    # 𝒲(m; T_m(δ)) back onto the reference grid
    moved: GeoArray["y x"] = to_s2(shifted)
    # the warped field on Ω
    moved_inner: GeoArray["y x"] = moved.isel(y=inner, x=inner)
    # m̃(δ)
    moved_std: Float[Array, "H W"] = standardise(moved_inner.data)
    # m̃ − r̃
    resid: Float[Array, "H W"] = moved_std - ref
    # ∑_Ω (m̃ − r̃)²
    return einx.dot("h w, h w ->", resid, resid)


# ∇δ misfit and ∇²δ misfit
grad_fn: Callable = jax.grad(misfit)
hess_fn: Callable = jax.hessian(misfit)

# Newton steps from δ = 0
delta: Float[Array, "2"] = jnp.zeros(2)
for _ in range(20):
    # g = ∇δ misfit, H = ∇²δ misfit at the current δ
    g: Float[Array, "2"] = grad_fn(delta)
    h: Float[Array, "2 2"] = hess_fn(delta)
    # H s = g, solved, never inverted
    h_op: lx.MatrixLinearOperator = lx.MatrixLinearOperator(h)
    step: Float[Array, "2"] = lx.linear_solve(h_op, g).value
    # δ ← δ − s
    delta = delta - step

# T_m(δ̂) applied to the enhancement layer
enhancement: GeoArray["band y x"] = z_on_s2.isel(band=slice(1, 2))
shifted_final: GeoArray["band y x"] = enhancement.shift_origin(delta)
# ΔX aligned on the Sentinel-2 grid
aligned: GeoArray["band y x"] = to_s2(shifted_final)

On a synthetic 64 × 64 scene with a 20 m grid and a true 7.3 m shift along x, the prototype recovered 7.296 m in 20 Newton steps, an error of 0.0002 pixel. That is the payoff of keeping the transform a leaf: the same code also batches scenes with different origins without recompiling.

Stage 5: patches for the detector (geopatcher → geoarray.crop)

With patch size h = w = 64 and stride s = 32 on a 600 × 600 chip, the patch origins are omn=(sm,sn)o_{mn} = (s m, s n) for m,n∈{0,1,…,16}m, n \in \{0, 1, \ldots, 16\}, which gives P = 17² = 289 patches. Patch p is x[…, o_p : o_p + (h, w)], and its transform shifts the origin by o_p = (o_{p,1}, o_{p,2}) (row, column):

cp=c+aop,2+bop,1,fp=f+dop,2+eop,1c_p = c + a o_{p,2} + b o_{p,1}, \qquad f_p = f + d o_{p,2} + e o_{p,1}

Offsets vary per patch and are traced; the size is static, which is what lax.dynamic_slice needs.

import geopatcher as gp

# x = [B11, B12, ΔX] on one grid, C = 3; grids checked, never aligned
parts: list[GeoArray["band y x"]] = [s2_jax, aligned]
stack: GeoArray["band y x"] = ga.concat(parts, dim="band")
# offsets planned on the host from the grid alone, then ga.crop vmapped over them in one trace;
# the result gains the patch dim and a (289, 6) transform; a GeoPatches
patches: GeoArray["patch band y x"] = gp.split(stack, size=64, overlap=32)

Each patch keeps its own transform row, so a detection at patch pixel (i, j) maps straight back to map coordinates. The prototype ran the same vmap(crop) pattern with three offsets in one trace and got the expected origins.

8Demo API

Further uses of the carrier, beyond the running example. Names are proposed.

A swath wrapper calling an L1 kernel: the TROPOMI averaging kernel

l is the retrieval layer (L = 12), N the swath pixels, A the column averaging kernel, x the model sub-column, xₐ the prior sub-column and Xₐ the prior XCH₄.

# L1, in radtrax: shapes only
def apply_column_kernel(
    ak: Float[Array, "L N"],
    model: Float[Array, "L N"],
    prior: Float[Array, "L N"],
    x_prior: Float[Array, "N"],
) -> Float[Array, "N"]:
    # xₗ − xₐ,ₗ
    departure: Float[Array, "L N"] = model - prior
    # ∑ₗ Aₗ (xₗ − xₐ,ₗ)
    correction: Float[Array, "N"] = einx.dot("l n, l n -> n", ak, departure)
    # Xₘ = Xₐ + ∑ₗ Aₗ (xₗ − xₐ,ₗ)
    return x_prior + correction


# behind radtrax[geo]: GeoModels may import GeoStack, never the reverse,
# so a wrapper that calls a radtrax kernel lives in radtrax, not in geotoolz.
# GeoSwathProfile ≡ GeoSwath["level row col"]; the column map is a GeoSwath["row col"]
def model_xch4(
    ak: GeoSwathProfile,
    model: GeoSwathProfile,
    prior: GeoSwathProfile,
    x_prior: GeoSwath["row col"],
) -> GeoSwath["row col"]:
    # level first, pixels flattened: (L, N)
    ak_rows: Float[Array, "L N"] = geoarray.to_rows(ak, dim="level")
    model_rows: Float[Array, "L N"] = geoarray.to_rows(model, dim="level")
    prior_rows: Float[Array, "L N"] = geoarray.to_rows(prior, dim="level")
    x_prior_rows: Float[Array, "N"] = geoarray.to_rows(x_prior)
    # the L1 call
    xm: Float[Array, "N"] = apply_column_kernel(
        ak_rows, model_rows, prior_rows, x_prior_rows
    )
    # back onto the swath: same lat, lon
    xm_swath: GeoSwath["row col"] = x_prior.with_rows(xm)
    return xm_swath

Points against a state: a data-assimilation cost

y is the observed SLA, H the sampling operator, R = σ²I the observation-error covariance, σ the along-track noise in m.

# along-track SLA, padded to a fixed N: values on obs; xy, time and valid are leaves (a GeoTracks)
obs: GeoPoints["obs"] = GeoPoints(
    sla,
    dims=("obs",),
    xy=lonlat,
    time=t_obs,
    valid=is_real,
    crs="EPSG:4326",
)


@jax.jit
def cost(ssh: GeoMap, obs: GeoTracks, sigma: float) -> Float[Array, ""]:
    # H(x): bilinear sample of the state at each observation
    predicted: GeoPoints["obs"] = geoarray.sample(ssh, obs)
    # y − H(x)
    innovation: Float[Array, "N"] = obs.data - predicted.data
    # m ∈ {0, 1}: 1 for a real observation, 0 for a padded slot
    m: Bool[Array, "N"] = obs.valid
    # d = m ⊙ (y − H(x)); padded slots give 0, never NaN, so ∇ₓJ stays finite
    d: Float[Array, "N"] = jnp.where(m, innovation, 0.0)
    # dᵀd
    dd: Float[Array, ""] = einx.dot("n, n ->", d, d)
    # J(x) = ½ dᵀ R⁻¹ d over real observations only
    return 0.5 * dd / sigma**2


# ∇ₓJ, the gradient a 4D-Var minimizer needs
grad_cost: Callable = jax.grad(cost)
grad: GeoArray["y x"] = grad_cost(ssh, obs, 0.03)

Positions change daily but are a leaf, so nothing recompiles; each day is padded to a fixed N, with valid (a boolean leaf, False on padding) as the mask. The mask goes through jnp.where, not a multiply: a NaN-filled pad times 0 is still NaN, and would poison both J and ∇ₓJ.

Running a geoops operator over a lazy cube (xrops.lift) is on the operators page.

9Migration

Two changes reach geotoolz, in order: the carrier replaces GeoTensor, then stored dims replace positional ones.

What the audit found

Five overlaps between georeader and geotoolz need action, and a sixth is healthy for NumPy but blocks JAX. The audit read georeader main at 9812be1 (v2.4.1, 7 Oct 2026) and geotoolz main at 48a705a on 17 Oct 2026.

SymbolsDefined inUsed byVerdict
GeoTensor (ndarray subclass with transform, crs, fill_value_default, attrs)georeader/geotensor.py (2,566 lines)files naming it: geoops 50, geopatcher 19, geoproducts 12, geocloud 6, geocatalog 5Split ownership. The type is georeader’s, but its rules (fresh attrs, NaN nodata, per-band keys) are geotoolz’s, in AGENTS.md and geoops._src
validmask, invalidmask vs valid_pixels, invalid_valuesgeotensor.py; geoops/_src/valid.pygeoops (2 calls to the former)True duplicate. georeader’s values != fill misses NaN fills; geoops’s version is correct
same_extent vs require_grid_match, grid_matchesgeotensor.py (transform tolerance 1e-3); geoops/_src/geo.py (exact)geoopsTrue duplicate. The two disagree for transforms that differ below 1e-3
window_from_bounds, round_outer_window, calculate_transform_window, read_from_windowgeoreader/read.py, georeader/window_utils.pygeopatcher (7 import lines)Split ownership. geopatcher reaches into georeader internals for window maths
resize, pad, meshgrid(dst_crs), read_reprojectgeotensor.py (via scikit-image), griddata.py, read.pygeoops 10 + 10 calls; geopatcherSplit ownership. These change values or grids, so they belong in geoops
__array_ufunc__ and 12 arithmetic dundersgeotensor.pyimplicitly, any gt + xHealthy for NumPy, but they rely on subclassing, which JAX arrays do not support

Smaller findings:

Step 1: one carrier, one coordinated release (GA-D1)

All five packages switch to GeoArray in one coordinated, breaking release; no operator ever accepts both carriers.

SymbolDestinationReason
geoops._src.wrap.wrap_likethe adapter’s rewrap; the array_as_geotensor path is removedone carrier, one rewrap
geoops._src.bands.PER_BAND_KEYSa SpectralAxis built by the readers and the georeader adapterband metadata lives on the coordinate
geocatalog, geocloud, geoproducts readersreturn carriersnative grid, NumPy data
  1. Switch every package on one branch per repo. geoops rewraps through the adapter only, and its validity, grid and band helpers call geoarray. The readers return carriers, and geopatcher replaces its seven georeader import lines with Grid.window and crop. A NumPy GeoArray implements __array__, so np.asarray(x) inside each operator keeps working.

  2. Reject GeoTensor at the door. A GeoTensor is an np.ndarray subclass, so np.asarray would accept it silently as a plain array and the output would lose its georeferencing. Each operator therefore checks its carriers before np.asarray and raises a TypeError naming from_georeader; there is no warning period.

  3. Drop georeader from the base dependencies. It moves to extras in all five packages; import-linter enforces that base modules never import it.

  4. Release together. The five packages and the umbrella publish in one release-please run, each with a BREAKING CHANGE: footer and a minor bump, and the umbrella pins the new set.

Downstream, radtrax.geo and plumax.geo take GeoTensors (radtrax backlog RTX-30 and RTX-34). They pin geotoolz below the breaking release until they migrate; each migration is one PR that calls from_georeader at its own boundary.

Step 2: stored dims

BeforeAfter
dims inferred from ndimdims stored; inferred from ndim for one release with a DeprecationWarning
3-D is always (band, y, x)any dims ending in the spatial dims, e.g. (time, y, x)
operators take axis=-3operators take dim="band"
band names in attrs["band_names"]a SpectralAxis in coords
GeoArray[np.ndarray] genericdims-string subscript (GeoArray["band y x"])
swaths and points have no carrierGeoSwath, GeoPoints
xarray conversions ad hoc per packageto_xarray() / from_xarray() in geoarray, lossless

This is one 0.X.0 bump of geotoolz-array, released like Step 1.

Phases

Step 1  GeoTensor → GeoArray
        type, geometry, interop: geotoolz-array 0.1.x
        gates GA-G1 to GA-G12
          │
          ▼
        one coordinated switch of ops, catalog, cloud,
        products, patcher; georeader to extras
        gates GA-G13, GA-G14
          │
          ▼
P0      spike: stored dims, 3 kernels,
        shared functions on 3 backends
        gates GA-G2, GA-G15 to GA-G22
          │
          ▼
P1      geoarray 0.2.0: stored dims, coords, types,
        check_dims, GeoSwath, GeoPoints, xarray converters;
        ndim inference deprecated; the 0.1.x suite passes
        with only the deprecation warning
          │
     ┌────┴────┐
     ▼         ▼
P2 I/O       P3 geopatcher
name dims,   split returns GeoPatches
GeoSwath     GA-G15
GA-G17
     └────┬────┘
          ▼
P4      geoops and xrops (operators page): dim=, backends,
        KernelOps, xrops.lift; after the pipekit-array release
        gates GA-G24 to GA-G26
          │
          ▼
P5      GeoModels: JAX-first kernels (radtrax, plumax),
        a coordax converter only if somax or vardax ask
        GA-G2 and GA-G16 rerun there

Versions only bump the minor number; nothing reaches 1.0 in this plan. Step 1’s type is the only piece that blocks everything else, and it is small: the prototype for it is under 200 lines.

10Testing gates

Each phase merges only when its gates pass. The “Was” column gives the gate’s ID on the two pages this one merges.

GateWasPasses when
GA-G1 Pytree round tripGA1unflatten(flatten(g)) is identical to g, and jit(lambda g: g)(g) returns equal data and transform bit for bit; if not: fix before anything else
GA-G2 One trace, many scenesGA2, G1one trace across 100 random transforms of the same shape, dims, CRS, fill and attrs; a new CRS or shape adds exactly one trace; if not: the transform is not a leaf; fix first
GA-G3 Attrs are hashableGA3equal attrs hash equal; lists become tuples; a dict value raises TypeError
GA-G4 x64 guardGA4to_jax raises with x64 off, and the transform is float64 with it on
GA-G5 Slice parityGB1for 1,000 random slices with steps 1 to 3, the transform equals GeoTensor.isel’s to 1e-9 in map units
GA-G6 Crop equals sliceGB2crop with a traced offset equals concrete slicing exactly; vmap over P offsets traces once
GA-G7 Window round tripGB3Grid.bounds_of(Grid.window(b)) == b exactly for grid-aligned b, and contains b otherwise
GA-G8 NaN nodataGB4valid_mask equals geoops._src.valid.valid_pixels on every geoops fixture, including NaN fills, where naive != fails (prototype: 15 valid of 16, versus 16)
GA-G9 Coordinates follow selectionGB5after any selection along a dim, its coordinate has one entry per remaining index
GA-G10 Backend parityGC1every geoarray function gives equal results on NumPy and JAX: exactly for indexing, to 1e-12 in float64 for pixel_coords; eager np.asarray(g) equals g.to_numpy().data, the same call raises GeoArrayTraceError under jit, grad and vmap, and mixed backends raise
GA-G11 georeader round tripGC2from_georeader(to_georeader(g)) is identical to g, for all geoops fixtures
GA-G12 Light base installGC3pip install geotoolz-array into a fresh environment installs none of jax, rasterio, georeader, xarray; if not: move the import behind its extra
GA-G13 One carrierGD1the full suites of all five packages pass on GeoArray fixtures, and every geoops operator given a GeoTensor raises a TypeError naming from_georeader
GA-G14 No georeader in baseGD2import-linter: no base module of the five packages imports georeader
GA-G15 PatchesG264 patches with different transforms run in one trace via map_over, and the stitch matches today’s within float tolerance; if not: keep patches as raw arrays inside the model, carriers outside
GA-G16 DA gradientG3jax.grad(cost) with respect to the state works with observations padded to a fixed N, and J and ∇ₓJ are unchanged by the pad length and finite with NaN-filled padding; if not: sample returns raw arrays; the carrier stops at the boundary
GA-G17 No JAX on I/OG4geocatalog, geocloud and geoproducts import and read with JAX not installed; if not: move the import behind [jax]
GA-G18 Shared functions agreeG5each shared function gives identical results on NumPy, JAX and xarray (dask-backed included); if not: drop it from the shared set
GA-G19 No silent alignmentG6combine and stack raise on a 10⁻⁷ m offset on every backend; if not: the xarray path is not shared
GA-G20 Lossless round tripG7from_xarray(to_xarray(g)) returns the same transform, dims and coordinates for random windows and rotated grids; the geotoolz #463 and #464 regression cases pass; if not: conventions are fixed before release
GA-G21 Patch overheadG810k patches through a 5-op chain cost no more than today’s carrier plus a budget you set; if not: profile and cut before release
GA-G22 Reproducible costsG9a committed benchmark script times the normalized difference and the window on the carrier and on xarray at the three shapes above, writes its output to a tracked file, and those numbers replace the indicative table; if not: drop the per-op cost argument from this page
GA-G23 Primitives stay pureG10a test in geostack’s dependency-graph suite finds no import of geoarray, xarray, pyproj, rasterio, geopandas or shapely in any */_src/array.py; if not: move the offending code up into the operator
GA-G24 Warp parity (geoops)GE1JAX bilinear warp equals rasterio’s bilinear reprojection to 1e-5 relative on interior pixels
GA-G25 CRS kernels (geoops)GE2each closed-form projection equals pyproj to 1 mm over its valid domain, in float64; if not: the CRS stays NumPy-only
GA-G26 Co-registration (geoops)GE3Stage 4 recovers a known sub-pixel shift to 0.01 pixel on a synthetic scene (prototype: 0.0002 pixel)

The three geoops gates check the JAX kernels geoarray depends on: bilinear and cubic warp (JAX’s map_coordinates supports order ≤ 1, so cubic needs a local kernel), mean and mode resampling with static block sizes and class counts, closed-form to_crs for EPSG:4326, UTM, Web Mercator, polar stereographic, sinusoidal and geostationary, and the GLT gather. A kernel without a JAX version raises an error naming the missing backend; it never converts silently.

11Decisions and open questions

IDs carry the page prefix GA-, so they stay unique across the design pages (decisions). Decisions from the GeoTensor design (17 Oct 2026) that the stored-dims design (21 Oct 2026) or the operators page replaced are marked superseded.

Decisions

IDQuestionStatusRecommended or decidedAlternative
GA-A1Subclass or composerecommended, to confirmcompose: a frozen dataclass holding datakeep the ndarray subclass for NumPy and add a JAX wrapper: two carrier types forever
GA-A2Transform precisionrecommended, to confirmfloat64 array leaf; to_jax requires jax_enable_x64static float64 origin plus a float32 leaf offset: no global flag, but a new origin recompiles
GA-A3Metadatarecommended, to confirm; narrowed 21 Octstatic FrozenAttrs of hashable values for whole-carrier metadata; per-band metadata on a SpectralAxisno attrs: breaks geoops’s metadata reads (41 places)
GA-A4Default fill valuerecommended, to confirmNone (no fill), set explicitly by readerskeep georeader’s 0 for parity
GA-B1Arithmeticrecommended, to confirmg.map(f) and combine(f, *gs); no dundersdunders with an eager grid check that skips under jit
GA-B2Per-band attrs on band selectionsuperseded 21 Octband metadata is a SpectralAxis coordinate, sliced with the data—
GA-C1NumPy interop for JAX datadecided 17 Oct; mixed-input promotion superseded 21 Octnp.asarray(g) copies eagerly and raises GeoArrayTraceError inside jit, grad or vmap; mixed NumPy and JAX inputs refuse and name to_jax(); operators declare backends and raise BackendErrornp.asarray(g) on a JAX carrier always raises
GA-D1Migration styledecided 17 Octone coordinated breaking release; GeoTensor is never accepted next to GeoArraya dual-carrier release in geoops first: two code paths in every operator
GA-E1Where JAX kernels livesuperseded 22 OctL1 kernels in geoops wrapped as KernelOps or called by geometry operators (operators), not a geoops.functional module—
GA-T1Series namesdecided 21 OctGeoImageSeries for frames on a grid, GeoTimeSeries for fixed locations; GeoVideo is not used (geotoolz #521, #518)—
GA-T2Spectral bands and variablesdecided 21 Octband and variable are separate dimsone dim for both
GA-T3Levels on observationsdecided 21 Octlevel is allowed on observations (TROPOMI layers)levels on gridded data only
GA-T4coordaxdecided 21 Octan optional converter, not the foundation of the JAX lane: its static coordinates would recompile per scene (labeled lane)coordax Field as the JAX carrier

Open questions

Settle these before the geotoolz issues #511–#534 are rewritten.

IDQuestionRecommendedAlternative
GA-Q1What do somax (esmax) and vardax take at their boundary?Shaped arrays plus a grid object (L1); the carrier stops in the wrapper behind [geo], so sample and H(x) live in geotoolzmodels take carriers directly, which makes to_coordax() or carrier-aware models necessary
GA-Q2How is the georeference written into xarray?CF grid_mapping plus a GeoTransform attribute, written by geoarray and readable by rioxarray, with no rioxarray dependency (labeled lane, where it is LL-F1)depend on rioxarray (pulls in rasterio and GDAL)
GA-Q3Where do the shared functions live?Decided 21 Oct 2026: pipekit-array, over a named-array adapter; geoarray registers its adapter and re-exports them—
GA-Q4What happens to geoops compositing?keep named operators as thin, serializable wrappers over stack, reduce, argreduce, take_alongremove them; users compose the shared functions
GA-Q5Do geoops operators accept a DataArray?kernel operators: yes, lazily through apply_ufunc; geometry operators: no, they raise and name xrops.lift (operators)convert every operator at entry and back at exit
GA-Q6map_over on xarrayallowed via apply_ufunc, documented as per-item Python cost unless dask-parallelizednot shared on xarray
GA-Q7Dim names: closed vocabulary or conventions?free names; geoops and the converters rely on the conventions (time, member, level, band, variable, patch); check_dims enforces each signaturea closed list, validated at construction
GA-Q8Curvilinear carrier nameGeoSwath, also used for fixed model gridsGeoCurvilinear, with GeoSwath an alias
GA-Q9The dims checkerDecided 21 Oct 2026: check_dims on pipekit.Signature patterns, the single Signature—
GA-Q10The existing issuesedit #511–#534 in place (numbers and links survive); block themes B–D on the spike #516; add pipekit and pipekit-array issues for the shared machineryclose and recreate

Before the issues open:

12Limitations

These come with the design; each has a workaround rather than a fix.

13Risks

14References