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.

Package layout across geotoolz and xrtoolz

Design · one job per distribution, matching family names, readers named as products

1Summary

We adopt geotoolz’s split for xrtoolz: distributions by job, modules inside the ops package by operation kind, and heavy backends as extras. geotoolz keeps its shape apart from a slimmer geoops base install, and no code moves between the two repos except bridges that become shared machinery. xrops sits one tier above geoops (the tiers), so every kernel lives in geoops or below and xrops adds only what needs xarray.

In the end state each repo has a code-free umbrella and the same job-shaped distributions, and geoops and xrops share family names where the job is the same.

2Ownership rules

We use one axis per level: distributions split by job, modules inside an ops package split by operation kind, and heavy backends sit behind extras.

Jobgeotoolzxrtoolz
metageotoolzxrtoolz (no code)
transformgeoops (tier 1)xrops (tier 2, new)
productsgeoproductsxrproducts (was xrreader)
carrygeoarrayxarray
sharedgeocatalog · geocloud · geopatchergeocatalog · geocloud · geopatcher

Each row is one job, and only the names on the right change. The shared row already serves both lanes, and pipekit-array (tier 0) sits under both ops packages.

  1. geotoolz and xrtoolz hold no code, as each depends on the distributions of its lane and documents the import table.

  2. geoops holds every kernel operator, written once, and every per-item geometry operator. Its base install is slim, since rasterio, geopandas and shapely, scikit-image and georeader sit behind [raster], [vector], [morph] and [sensors], and it has no [xarray] extra and never imports xrops or xarray.

  3. xrops holds xarray-native work (selection by value, many files, Dataset and DataTree), xrops.lift for geoops operators over cubes, and state physics (atm, ocn, budgets). It may import geoops and geoarray, and crossing lanes goes through the converters of geoarray (labeled lane).

  4. Domain physics on model states lives in xrops, whereas on observations it belongs to GeoModels (plumax, radtrax) and never to geoops (stack levels, SL-O1).

  5. geoproducts and xrproducts read provider data into the carrier of their lane and never transform it.

  6. Heavy backends (JAX, einx, scikit-learn) are extras and never base dependencies.

  7. Every package writes the three levels the same way: primitives in _src/array.py, operators on carriers above them, and carrier code only where the carrier may be imported.

3Current-state audit

Six mismatches need action. We read the main branches of geotoolz (umbrella 0.12.0) and xrtoolz (0.0.5) on 21 Oct 2026 for this audit, and the call-site counts are still to do.

SymbolsDefined inUsed byVerdict
Umbrellageotoolz/__init__.py (no code); xrtoolz (code + hard deps on all five siblings)every installSplit ownership. xrtoolz is both the ops package and the meta package
Operator basepipekit_ops.Operator + geoops._src.contract; xrcore.operator.Operator (DataTree dispatch)all operatorsSplit ownership. Base lives in the ops package on one side, its own distribution on the other
einx bridgegeoops.einx; xreinx (dist), with a xrtoolz.einx shimpipelinesTrue duplicate. Same job, module vs distribution
sklearn bridgegeoops.learn; xrsklearn (dist)pipelinesTrue duplicate. Module vs distribution, and learn vs sklearn
Geometrygeoops.geom + geoops._src.engine; xrtoolz.geo (crs, mosaic, rasterize, vectorize, footprint, masks, wavelets, extremes, detrend, …)both lanesSplit ownership. xrtoolz.geo mixes geometry with analysis
RS physicsgeoops.indices, geoops.radiometry; xrtoolz.rs (empty, plans NDVI and radiance-to-reflectance)none yetTrue duplicate in plan. Observation kernels belong to geoops and already run on cubes

Smaller findings:

4Target layout and moves

Every job has the same distribution shape in both repos, and every ops family has the same name where the job is the same.

Jobgeotoolzxrtoolz todayxrtoolz after
metageotoolzxrtoolz (code + meta)xrtoolz (no code)
transformgeoops, slim base plus [raster], [vector], [morph], [sensors]xrtoolzxrops
Operator basepipekit_ops.Operator in geoopsxrcorexrops._src; pipekit_ops.Signature (PL-Q2)
tensor algebrageoops.einxxreinxpipekit-array [einx], shared (OP-O2)
ML bridgegeoops.learnxrsklearnpipekit-array to_rows / from_rows and one estimator wrapper (OP-O3)
calculusgeoops.geom.Gradientxrgradxrops.Gradient over the geoops kernel (OP-O7)
lifting over cubes——xrops.lift (OP-O4)
geometrygeoops.geomxrtoolz.geoxrops.geom + analysis families (PL-Q4)
RS physicsgeoops.indices, radiometryxrtoolz.rs (empty)removed
state physics—xrtoolz.atm, ocn, budgetsxrops.atm, ocn, budgets (PL-Q6)
productsgeoproductsxrreaderxrproducts (PL-Q3)
carrygeoarrayxarrayunchanged
read + index, store, patchgeocatalog, geocloud, geopatchersharedunchanged
  1. Create xrtoolz-ops. We move every module of the xrtoolz distribution into xrops with history, and the tests stay unchanged apart from imports.

  2. Empty the umbrella. xrtoolz depends on xrtoolz-ops and xrtoolz-products, re-exports nothing but __version__, and documents the import table, as geotoolz/__init__.py does.

  3. Retire the bridges. Per PL-Q1, xreinx and xrsklearn are removed once the bridges of pipekit-array ship (OP-O2, OP-O3), and xrgrad becomes xrops.Gradient.

  4. Rename the families. Per PL-Q4, we split xrtoolz.geo and delete rs and ice.

  5. Rename the reader. Per PL-Q3, xrtoolz-reader becomes xrtoolz-products.

  6. No shims. Old import paths are removed outright, as geotoolz does (PL-Q5), so the release that moves a module updates every caller in the stack and nothing re-exports the old path.

5Boundary: geoproducts and xrproducts

The line is what one read returns: geoproducts reads one acquisition into a geoarray carrier, whereas xrproducts reads anything stacked over time, members or levels, or a station series, into xarray (PL-Q7). This follows the workload rule of the crossover, and it replaces the 21 Oct wording of observations versus states drawn at an L3 carrier switch.

Provenance alone does not split cleanly, as CMEMS serves both gridded satellite maps and the GLORYS reanalysis, and CDS serves ERA5 and satellite products. So a provenance rule would split the reader of one provider across two packages.

ProductLevelOne read returnsPackage
Sentinel-2 L1C, Landsat 8/9 L1L1one scene on its gridgeoproducts
EMIT L1B, L2BL1, L2one scene in sensor geometry (GeoSwath)geoproducts
GOES ABI, Himawari AHIL1one scangeoproducts
Gridded SLA maps (DUACS)L4a cube over timexrproducts
GLORYS12 reanalysisL4a state cubexrproducts
ERA5L4a state cubexrproducts
AEMET stationsin situa station seriesxrproducts
  1. Both read, neither transforms. A reader returns the carrier of its lane on the native grid of the provider, and regridding and derived fields are operators.

  2. Neither imports the other. A product that needs both (EMIT scenes matched to ERA5 winds) is joined in geocatalog or a pipekit graph.

  3. Rename the catalog module of xrreader. xrreader._src.catalog is a short-name registry (glorys12.daily → source and dataset ID) and not a catalog, so it becomes xrproducts.registry and “catalog” means geocatalog everywhere.

Note: how geocatalog handles states

geocatalog already indexes states on main through geocatalog.states, so this plan only adds the hand-off from xrproducts.

The split of work:

  1. xrproducts fetches. It handles authentication, the provider request, download, cache and opening one dataset.

  2. geocatalog indexes and loads. Many files become rows, the rows are queried, and load_states builds the cube, as it does today.

  3. The hand-off is rows, not imports. xrproducts writes state-catalog rows (or calls build_state_catalog on what it downloaded) so a fetch lands indexed, and geocatalog never imports xrproducts, whereas xrproducts depends on geocatalog only through an extra (PL-Q8).

6The elephants: points, polygons, rasters, states

Each geometry kind gets one carrier per lane, and changing kind (rasterize, sample, bin) is an operator and never the job of the carrier. On the array side the carriers are GeoArray (grids), GeoSwath (sensor geometry) and GeoPoints (point sets), with stored dims and time stamps on a TimeAxis (geoarray), whereas xarray formalizes neither geometry nor time meaning and conventions do (CF, xvec).

KindExamplesArray lane (NumPy / JAX)Labeled lane (xarray)At rest
RasterSentinel-2 scene, EMIT L1BGeoArray, GeoSwathDataArray on (y, x) or (row, col)COG
StateGLORYS12, ERA5, a somax runshaped arrays in the model; a carrier slice at its boundary, e.g. for H(x)Dataset on (time, member, level, y, x)Zarr, NetCDF
Polygonsplume outlines, AOIs, field boundariesrasterized to GeoArray by an operatorxvec DataArray with a geometry dimGeoParquet
PointsArgo profiles, along-track SSH, AEMET stations, plume source pointsGeoPointsDataset on one obs dim (CF DSG)GeoParquet, NetCDF DSG

Point clouds: the gap

Points have three shapes today and no carrier:

The fix, one carrier per lane (PL-Q9):

  1. Labeled lane: a CF Discrete Sampling Geometry dataset. One obs dim, with lon, lat, time and depth as coordinates on it, and featureType naming the layout (point, timeSeries, trajectory, profile). Ragged profiles use the contiguous ragged layout of DSG, one row_size per feature, and Argo and CMEMS along-track files already ship this way, so xrproducts opens them with no conversion.

  2. xvec only on demand. We build an xvec geometry index on obs only when a spatial query needs it, since one shapely object per sample is the cost to avoid at 10⁷ along-track points.

  3. Array lane: GeoPoints in geoarray. It holds horizontal xy (obs, 2), an optional z (obs,) with its own vertical descriptor, values, per-sample time, a CRS and a fill, as a pytree; the geoarray page defines it and says why z is never a third column. vardax and filterax need it, because an observation operator maps a gridded state to values at points inside jit and grad, and a GeoDataFrame cannot cross jit.

  4. Kind changes are operators with matching names in both lanes. These are sample (raster to points), bin or grid (points to raster), and rasterize and vectorize (polygons to and from raster). The converters of geoarray cross lanes (GeoPoints to and from a DSG dataset), as for grids (labeled lane).

  5. At rest. We use GeoParquet for point tables that geocatalog queries with DuckDB, and NetCDF DSG or Zarr when a profile or a trajectory is the unit.

Alternatives considered:

When to leave a carrier for xarray is the subject of the crossover.

7Open questions

Each question has a recommendation and its alternative, and we can flip any of them before the issues open.

IDQuestionRecommendedAlternativeStatus
PL-Q1Backend bridges: extras or distributions?Retire xreinx and xrsklearn in favour of pipekit-array’s shared bridges (OP-O2, OP-O3); xrgrad becomes xrops.Gradient (OP-O7)Fold them into xrops behind extras (the 21 Oct recommendation), or split geoops.einx and geoops.learn out into distributionssuperseded by OP-O2, OP-O3, OP-O7
PL-Q2Where the xarray Operator base livesDecided 29 Oct 2026: xrcore folds into xrops._src, as geoops keeps its base; Signature is pipekit_ops.SignatureKeep xrcore if something outside xrtoolz needs the base without the opsdecided 29 Oct 2026 (Signature 21 Oct, GA-Q9)
PL-Q3Reader nameDecided 29 Oct 2026: xrtoolz-products (import xrproducts), matching geoproductsKeep xrreaderdecided 29 Oct 2026
PL-Q4How to split xrtoolz.geoDecided 29 Oct 2026: geometry (crs, mosaic, rasterize, vectorize, footprint, subset) → xrops.geom; analysis (detrend, wavelets, extremes, regimes) into named familiesKeep geo as the generic family and rename geoops.geom to matchdecided 29 Oct 2026
PL-Q5Shim lifetimeDecided 29 Oct 2026: no shims; old import paths are removed outright in the release that moves them, and the stack’s callers move in the same release, as in geotoolza one-release DeprecationWarning shim (the earlier recommendation), or shims through the whole 0.x seriesdecided 29 Oct 2026
PL-Q6Domain familiesxrops.atm, ocn, budgets stay, because they act on model states; geoops stays domain-neutralMove them to GeoModels packages next to their physicsdecided 22 Oct 2026 (OP-O1); the split by discretisation is SL-O1
PL-Q7geoproducts versus xrproductsDecided 29 Oct 2026: what one read returns: one acquisition → geoproducts; a cube or a station series → xrproductsStrict provenance (observations versus model output), splitting each provider’s reader across bothdecided 29 Oct 2026
PL-Q8Who assembles state cubesDecided 29 Oct 2026: load_states stays in geocatalog, which already serves both lanes; xrproducts hands off rowsMove cube assembly into xrproducts and keep geocatalog index-onlydecided 29 Oct 2026
PL-Q9Point carriersCF DSG Dataset in the labeled lane, GeoPoints in the array laneOne table carrier for both lanes, or xvec for pointsGeoPoints decided (geoarray page); DSG open

8Gates, sequencing and risks

Each workstream merges only when its gate passes; PL-A ships first because every other move lands inside xrops.

GateWorkstreamPasses when
A1 Port parityPL-Athe moved tests pass with only import changes, and the test count equals the old xrtoolz suite’s
A2 Empty umbrellaPL-Axrtoolz/__init__.py exports only __version__, and import xrtoolz imports no ops module
B1 Light basePL-Ba fresh pip install xrtoolz-ops installs none of jax, einx, scikit-learn
C1 One basePL-Cevery xrops operator is an instance of the one base class, and xrcore no longer exists
D1 Name parityPL-Da script lists the families of geoops and xrops; every name in both means the same job, and the list is checked into the docs
E1 Reader renamePL-Eevery public name of xrreader is importable from xrproducts, and import xrreader fails

The order is PL-A, then PL-B and PL-E in parallel, then PL-C, then PL-D. PL-C waits for PL-B: xreinx and xrsklearn subclass xrcore.Operator and xrops depends on xrsklearn, so folding xrcore into xrops before those bridges are deleted would make a cycle. PL-B waits for the pipekit-array release that ships the shared bridges (OP-O2, OP-O3), and xrops.lift waits for the slim base of geoops (operators). Each is one minor bump of xrtoolz (0.1.0, then 0.2.0).