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.
PL-A. xrtoolz becomes a meta package. Today
xrtoolzis both the ops code and the umbrella that hard-depends on every sibling, so we move its code to a newxrtoolz-ops(importxrops) and leavexrtoolzwith no code, asgeotoolzhas.PL-B. Backend bridges stop being distributions.
xreinxandxrsklearngive way to one named-dims bridge each in pipekit-array (OP-O2, OP-O3), whereasxrgradbecomesxrops.Gradientover the geoops kernel (OP-O7).PL-C. One home for the xarray Operator base. The
Operatorofxrcore(with itsDataTreedispatch) folds intoxrops._src, and itsSignatureis replaced bypipekit_ops.Signature, with no re-export (PL-Q5; pipekit kernels).PL-D. Matching family names. geoops is organised by operation kind, whereas xrtoolz is organised by domain plus a
geograb-bag, soxropstakes the family names of geoops wherever the job is the same, keeps the state-physics families, and drops the emptyrsandicestubs.PL-E. Readers named as products.
xrreader(CMEMS, CDS, AEMET) is the xarray twin ofgeoproducts, so it becomesxrtoolz-products(importxrproducts).
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.
| Job | geotoolz | xrtoolz |
|---|---|---|
| meta | geotoolz | xrtoolz (no code) |
| transform | geoops (tier 1) | xrops (tier 2, new) |
| products | geoproducts | xrproducts (was xrreader) |
| carry | geoarray | xarray |
| shared | geocatalog · geocloud · geopatcher | geocatalog · 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.
geotoolz and xrtoolz hold no code, as each depends on the distributions of its lane and documents the import table.
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.xrops holds xarray-native work (selection by value, many files,
DatasetandDataTree),xrops.liftfor 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).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).geoproducts and xrproducts read provider data into the carrier of their lane and never transform it.
Heavy backends (JAX, einx, scikit-learn) are extras and never base dependencies.
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.
| Symbols | Defined in | Used by | Verdict |
|---|---|---|---|
| Umbrella | geotoolz/__init__.py (no code); xrtoolz (code + hard deps on all five siblings) | every install | Split ownership. xrtoolz is both the ops package and the meta package |
| Operator base | pipekit_ops.Operator + geoops._src.contract; xrcore.operator.Operator (DataTree dispatch) | all operators | Split ownership. Base lives in the ops package on one side, its own distribution on the other |
| einx bridge | geoops.einx; xreinx (dist), with a xrtoolz.einx shim | pipelines | True duplicate. Same job, module vs distribution |
| sklearn bridge | geoops.learn; xrsklearn (dist) | pipelines | True duplicate. Module vs distribution, and learn vs sklearn |
| Geometry | geoops.geom + geoops._src.engine; xrtoolz.geo (crs, mosaic, rasterize, vectorize, footprint, masks, wavelets, extremes, detrend, …) | both lanes | Split ownership. xrtoolz.geo mixes geometry with analysis |
| RS physics | geoops.indices, geoops.radiometry; xrtoolz.rs (empty, plans NDVI and radiance-to-reflectance) | none yet | True duplicate in plan. Observation kernels belong to geoops and already run on cubes |
Smaller findings:
Domain modules.
xrtoolz.atm,ocnandbudgetsact on gridded states, whereasxrtoolz.iceis an empty stub. Theplumeandmatched_filtermodules of geoops are already scheduled to leave for plumax and radtrax (radtrax backlog RTX-34, RTX-30).Readers.
xrreaderreads CMEMS, CDS and AEMET archives into xarray, which is the job ofgeoproducts, but it is named like a different kind of package.Shim policy.
xrtoolz._operator,xrtoolz.signatureandxrtoolz.einxsay they are removed at 1.0. However, the stack bumps 0.X.0 for breaking changes, so the removal needs a named 0.X release.Shared packages stay shared.
geocatalog(withgeocatalog.states),geocloudandgeopatcheralready serve both lanes, and nothing in xrtoolz duplicates them.
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.
| Job | geotoolz | xrtoolz today | xrtoolz after |
|---|---|---|---|
| meta | geotoolz | xrtoolz (code + meta) | xrtoolz (no code) |
| transform | geoops, slim base plus [raster], [vector], [morph], [sensors] | xrtoolz | xrops |
| Operator base | pipekit_ops.Operator in geoops | xrcore | xrops._src; pipekit_ops.Signature (PL-Q2) |
| tensor algebra | geoops.einx | xreinx | pipekit-array [einx], shared (OP-O2) |
| ML bridge | geoops.learn | xrsklearn | pipekit-array to_rows / from_rows and one estimator wrapper (OP-O3) |
| calculus | geoops.geom.Gradient | xrgrad | xrops.Gradient over the geoops kernel (OP-O7) |
| lifting over cubes | — | — | xrops.lift (OP-O4) |
| geometry | geoops.geom | xrtoolz.geo | xrops.geom + analysis families (PL-Q4) |
| RS physics | geoops.indices, radiometry | xrtoolz.rs (empty) | removed |
| state physics | — | xrtoolz.atm, ocn, budgets | xrops.atm, ocn, budgets (PL-Q6) |
| products | geoproducts | xrreader | xrproducts (PL-Q3) |
| carry | geoarray | xarray | unchanged |
| read + index, store, patch | geocatalog, geocloud, geopatcher | shared | unchanged |
Create
xrtoolz-ops. We move every module of thexrtoolzdistribution intoxropswith history, and the tests stay unchanged apart from imports.Empty the umbrella.
xrtoolzdepends onxrtoolz-opsandxrtoolz-products, re-exports nothing but__version__, and documents the import table, asgeotoolz/__init__.pydoes.Retire the bridges. Per PL-Q1,
xreinxandxrsklearnare removed once the bridges of pipekit-array ship (OP-O2, OP-O3), andxrgradbecomesxrops.Gradient.Rename the families. Per PL-Q4, we split
xrtoolz.geoand deletersandice.Rename the reader. Per PL-Q3,
xrtoolz-readerbecomesxrtoolz-products.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.
| Product | Level | One read returns | Package |
|---|---|---|---|
| Sentinel-2 L1C, Landsat 8/9 L1 | L1 | one scene on its grid | geoproducts |
| EMIT L1B, L2B | L1, L2 | one scene in sensor geometry (GeoSwath) | geoproducts |
| GOES ABI, Himawari AHI | L1 | one scan | geoproducts |
| Gridded SLA maps (DUACS) | L4 | a cube over time | xrproducts |
| GLORYS12 reanalysis | L4 | a state cube | xrproducts |
| ERA5 | L4 | a state cube | xrproducts |
| AEMET stations | in situ | a station series | xrproducts |
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.
Neither imports the other. A product that needs both (EMIT scenes matched to ERA5 winds) is joined in geocatalog or a pipekit graph.
Rename the
catalogmodule ofxrreader.xrreader._src.catalogis a short-name registry (glorys12.daily→ source and dataset ID) and not a catalog, so it becomesxrproducts.registryand “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.
Rows. A state catalog is an xarray-kind catalog with five reserved columns (schema v1):
kind,run_time,member,producer,parents.build_state_catalogfills them from the metadata of each file, withCF_NAMESandCFGRIB_NAMESas name maps.Queries.
forecasts,ensemble_at,forecast_chain,lead_window,at_timeandlatest_analysiswork on either backend.Loading.
load_statesstacks one file per (run, member) into an(init_time, member, lead_time, y, x)cube, lazily, behind the[xarray-raster]extra, and overlapping runs are never dropped.
The split of work:
xrproducts fetches. It handles authentication, the provider request, download, cache and opening one dataset.
geocatalog indexes and loads. Many files become rows, the rows are queried, and
load_statesbuilds the cube, as it does today.The hand-off is rows, not imports. xrproducts writes state-catalog rows (or calls
build_state_catalogon 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).
| Kind | Examples | Array lane (NumPy / JAX) | Labeled lane (xarray) | At rest |
|---|---|---|---|---|
| Raster | Sentinel-2 scene, EMIT L1B | GeoArray, GeoSwath | DataArray on (y, x) or (row, col) | COG |
| State | GLORYS12, ERA5, a somax run | shaped arrays in the model; a carrier slice at its boundary, e.g. for H(x) | Dataset on (time, member, level, y, x) | Zarr, NetCDF |
| Polygons | plume outlines, AOIs, field boundaries | rasterized to GeoArray by an operator | xvec DataArray with a geometry dim | GeoParquet |
| Points | Argo profiles, along-track SSH, AEMET stations, plume source points | GeoPoints | Dataset on one obs dim (CF DSG) | GeoParquet, NetCDF DSG |
Point clouds: the gap¶
Points have three shapes today and no carrier:
The
RasterToPoints,PointsToRaster,RasterToPointCloudandPointCloudToRasteroperators of geoops take a GeoDataFrame or an xvec array.The along-track helpers of
xrtoolz.geotake bare 1-D lon and lat arrays.geocatalog.matchuppairs catalog rows and not points.
The fix, one carrier per lane (PL-Q9):
Labeled lane: a CF Discrete Sampling Geometry dataset. One
obsdim, with lon, lat, time and depth as coordinates on it, andfeatureTypenaming the layout (point, timeSeries, trajectory, profile). Ragged profiles use the contiguous ragged layout of DSG, onerow_sizeper feature, and Argo and CMEMS along-track files already ship this way, so xrproducts opens them with no conversion.xvec only on demand. We build an xvec geometry index on
obsonly when a spatial query needs it, since one shapely object per sample is the cost to avoid at 10⁷ along-track points.Array lane:
GeoPointsin geoarray. It holds horizontalxy(obs, 2), an optionalz(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 whyzis never a third column. vardax and filterax need it, because an observation operator maps a gridded state to values at points insidejitandgrad, and a GeoDataFrame cannot crossjit.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 (
GeoPointsto and from a DSG dataset), as for grids (labeled lane).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:
One table carrier (GeoDataFrame or GeoArrow) for points in both lanes. It is best for huge sets and DuckDB, but it is not labeled N-D and not
jit-able.xvec for points too. It gives one vector story, but one shapely object per point at scale.
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.
| ID | Question | Recommended | Alternative | Status |
|---|---|---|---|---|
| PL-Q1 | Backend 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 distributions | superseded by OP-O2, OP-O3, OP-O7 |
| PL-Q2 | Where the xarray Operator base lives | Decided 29 Oct 2026: xrcore folds into xrops._src, as geoops keeps its base; Signature is pipekit_ops.Signature | Keep xrcore if something outside xrtoolz needs the base without the ops | decided 29 Oct 2026 (Signature 21 Oct, GA-Q9) |
| PL-Q3 | Reader name | Decided 29 Oct 2026: xrtoolz-products (import xrproducts), matching geoproducts | Keep xrreader | decided 29 Oct 2026 |
| PL-Q4 | How to split xrtoolz.geo | Decided 29 Oct 2026: geometry (crs, mosaic, rasterize, vectorize, footprint, subset) → xrops.geom; analysis (detrend, wavelets, extremes, regimes) into named families | Keep geo as the generic family and rename geoops.geom to match | decided 29 Oct 2026 |
| PL-Q5 | Shim lifetime | Decided 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 geotoolz | a one-release DeprecationWarning shim (the earlier recommendation), or shims through the whole 0.x series | decided 29 Oct 2026 |
| PL-Q6 | Domain families | xrops.atm, ocn, budgets stay, because they act on model states; geoops stays domain-neutral | Move them to GeoModels packages next to their physics | decided 22 Oct 2026 (OP-O1); the split by discretisation is SL-O1 |
| PL-Q7 | geoproducts versus xrproducts | Decided 29 Oct 2026: what one read returns: one acquisition → geoproducts; a cube or a station series → xrproducts | Strict provenance (observations versus model output), splitting each provider’s reader across both | decided 29 Oct 2026 |
| PL-Q8 | Who assembles state cubes | Decided 29 Oct 2026: load_states stays in geocatalog, which already serves both lanes; xrproducts hands off rows | Move cube assembly into xrproducts and keep geocatalog index-only | decided 29 Oct 2026 |
| PL-Q9 | Point carriers | CF DSG Dataset in the labeled lane, GeoPoints in the array lane | One table carrier for both lanes, or xvec for points | GeoPoints 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.
| Gate | Workstream | Passes when |
|---|---|---|
| A1 Port parity | PL-A | the moved tests pass with only import changes, and the test count equals the old xrtoolz suite’s |
| A2 Empty umbrella | PL-A | xrtoolz/__init__.py exports only __version__, and import xrtoolz imports no ops module |
| B1 Light base | PL-B | a fresh pip install xrtoolz-ops installs none of jax, einx, scikit-learn |
| C1 One base | PL-C | every xrops operator is an instance of the one base class, and xrcore no longer exists |
| D1 Name parity | PL-D | a 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 rename | PL-E | every 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).
Downstream imports break. pipekit, geostack notebooks and GeoBench import
xrtoolz.*today. Mitigation: there are no shims (PL-Q5), so each release greps the stack for the old paths and updates every caller in the same release.The shared bridges slip. If the einx and scikit-learn bridges of pipekit-array are late, PL-B blocks. Fallback: we keep
xreinxandxrsklearnas they are for one more release, and PL-D still applies.xrtoolz.geohas no clean split. Some functions are both geometry and analysis. Fallback: we place each such function by its main job and list it in the checked-in table of D1.