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.

The geo data stack

Report 16 · Realignment plan for pipekit, xrtoolz and geotoolz across 18 distributions

Abstract

Three repositories ship 18 distributions: pipekit (7), xrtoolz (6) and geotoolz (5). None of them merges into another. pipekit is the composition layer; geotoolz-cloud, geotoolz-catalog and geotoolz-patcher serve both carrier lanes; geotoolz and geotoolz-products are the GeoTensor lane; the xrtoolz family is the xarray lane. One synthetic methane scene runs through the packages that touch it (pipekit and pipekit-array, geocatalog, geopatcher, geotoolz and the xrtoolz core, reader and top-level package) and the matched filter’s noise lands within 3.2 % of its closed form. Six seams still need work: two Signature classes that cannot share a pipeline, eight model wrappers for three backends, shared xarray vocabulary that lives in a downloader, a training dataset that imports a geopatcher module removed on 8 October, no rule for where a mission reader lives, and a distribution name on PyPI that belongs to another project. The running example exercises four of them on the pinned code (the signatures, the vocabulary’s dependency, the training dataset and the dependency names); the model wrappers and the mission readers are read from the source in the audit, and pipekit-jax, geotoolz-products, xrtoolz-einx, xrtoolz-grad and xrtoolz-sklearn are not imported by the run. This page decides each one and pulls together the open questions Reports 3, 6, 7 and 11 left on them.

Keywords:pipekitgeotoolzgeotoolz-cataloggeotoolz-patchergeotoolz-cloudgeotoolz-productsxrtoolzpackage ownership

Summary

Decision. No package merges into another, and the lanes stay apart: no GeoTensor package imports an xarray one or the reverse. Each overlap below gets one owner, every move but one leaves a deprecated alias for the 0.x series (the window dataset’s old import, which does not work today), and the stack publishes under names nobody else holds.

End state. pipekit-core holds every carrier-agnostic contract and imports nothing from the geo stack. geotoolz-cloud moves bytes, geotoolz-catalog says which data, geotoolz-patcher says which piece, geotoolz computes on GeoTensors, geotoolz-products decodes gridded missions, and the xrtoolz family computes on xarray over xrtoolz-core’s vocabulary. All 18 distributions install from PyPI into one environment.

Ownership rules

Foundations sit at the top and every arrow points up: a package imports only from tiers above it, and the two lanes never import each other (Figure 1; the tiers are Table 1). The geophysical model packages that sit on this stack follow their own rules.

Tier 1: pipekit-core, and the external georeader, xarray and obstore. Tier 2: the pipekit subpackages, geotoolz-cloud and xrtoolz-core. Tier 3, shared by both lanes: geotoolz-catalog and geotoolz-patcher, with the proposed integrations.train module. Tier 4: the GeoTensor lane (geotoolz, geotoolz-products) and the xarray lane (xrtoolz, its einx, grad and sklearn bridges, and xrtoolz-reader), which never import each other. Scripts at the bottom.

Figure 1:The geo data stack after the realignment. Solid arrows are base dependencies, dashed ones optional extras; the dashed blue box is the one new surface.

  1. pipekit-core (import pipekit) owns Operator, Sequential, Graph, Signature, serialisation, control flow, the parallel maps, observation, QC and the shared protocols. It has no dependencies and never imports xarray, georeader or a geo package.

  2. pipekit-array, -cycle, -evaluate, -experiment, -jax and -train own orchestration on that contract: Array API operators, cycles, benchmarks, tracking, the one Equinox model operator, and training loops. None imports a geo package (today pipekit-train does; Section ??).

  3. geotoolz-cloud (import geocloud) owns object storage: the process-wide obstore pool, URI verbs, credentials, local copies, COG and HDF reads and writes, and the fsspec view. It imports georeader, obstore and rasterio, and never pipekit or another geo package.

  4. geotoolz-catalog (import geocatalog) owns which data: GeoSlice, catalogs and their backends, discovery sources with footprints (STAC, CMR, earthaccess, GEE), matchup, staging and field_for. It serves both lanes, and reaches geocloud and geopatcher only through its [cloud] and [patch] extras.

  5. geotoolz-patcher (import geopatcher) owns which piece for every carrier: split and merge, temporal stencils, the patch cache, and the integrations that put patching into a pipekit graph or, after D, a pipekit-train dataset. It imports pipekit and geocloud only inside those integrations and fields.

  6. geotoolz-products (import geoproducts) owns decoding missions whose output is a georeferenced raster: GOES-R ABI, Himawari AHI, Carbon Mapper, the toy sensor. Its presets bind geotoolz operators to channel names and import geotoolz only inside the [operators] extra.

  7. geotoolz owns operators on GeoTensor: reads of one source, radiometry, indices, masks, matched filters, plume quantification, and GeoTensor marshalling for models. It imports pipekit, and geopatcher and geocloud only through extras; it never imports xarray-lane packages.

  8. xrtoolz-core (import xrcore) owns the xarray Operator and, after C, the shared xarray vocabulary: the CF variable registry, validation and subsetting. It imports pipekit, xarray and NumPy, and after C pandas, which the moved subsetting code uses and which it declares directly (xarray already requires it, so the install does not grow).

  9. xrtoolz-einx, xrtoolz-grad and xrtoolz-sklearn own labelled einsum, finite differences and the one xarray scikit-learn bridge. They import xrtoolz-core at most.

  10. xrtoolz-reader (import xrreader) owns request-subset archives (CMEMS, CDS, AEMET) and mission readers whose output is a swath, 2-D latitude and longitude, profiles or stations. After C it imports the vocabulary from xrtoolz-core.

  11. xrtoolz owns diagnostics, metrics and ocean and atmosphere physics on xarray. It imports the xrtoolz family and pipekit, and xrtoolz-reader only through a [reader] extra after C.

  12. The lanes meet only in scripts. A script that needs both lanes passes arrays between them; radiometry and spectral indices stay in geotoolz, and xrtoolz does not grow a second copy.

Table 1:The dependency tiers after the realignment.

TierPackagesMay importRole
1 Foundationspipekit-core; georeader, xarray, obstore (external)nothing in the stackcontracts, carriers, bytes
2 Framework and storagepipekit-array, -cycle, -evaluate, -experiment, -jax, -train; geotoolz-cloud; xrtoolz-coretier 1orchestration; object storage; the xarray operator and vocabulary
3 Shared geogeotoolz-catalog, geotoolz-patchertiers 1–2 (pipekit and geocloud through extras)which data, which piece
4 Lanesgeotoolz, geotoolz-products · xrtoolz, -einx, -grad, -sklearn, -readertiers 1–3, never the other lanecompute and decode on one carrier
5 Entry pointsgeostack scripts and notebookseverythingcompose both lanes

The running example: one methane scene through both lanes

The problem. A 256 × 256 scene of 30 m pixels in UTM zone 13N, observed in c=40c = 40 SWIR bands between 2100 and 2450 nm on 15 July 2026, contains a methane plume. The goal is the emission rate QQ in kg s⁻¹. The scene is synthetic so every stage has an exact answer to check against. The methane chain’s own physics is scoped in GeoModels: the retrieval in radtrax · 1, the emission rate in plumax · 1, and where each piece moves in the package boundaries page; this page checks only how the data stack carries it.

The data. Let xp∈Rcx_p \in \mathbb{R}^c be the radiance spectrum of pixel pp, μ∈Rc\mu \in \mathbb{R}^c the background mean, Σ∈Rc×c\Sigma \in \mathbb{R}^{c \times c} the background covariance (rank 6 plus a diagonal), t∈Rct \in \mathbb{R}^c the unit-absorption target (the radiance change per ppm·m of methane, built from two absorption bands near 2300 and 2360 nm) and αp\alpha_p the enhancement in ppm·m, peaking at 1500 ppm·m. Each pixel follows the linear model (1):

xp=μ+Lzp+αpt,zp∼N(0,Ic),LL⊤=Σ.x_p = \mu + L z_p + \alpha_p t , \qquad z_p \sim \mathcal{N}(0, I_c) , \qquad L L^\top = \Sigma .

The best a matched filter can do is set by the noise floor σα=(t⊤Σ−1t)−1/2\sigma_\alpha = (t^\top \Sigma^{-1} t)^{-1/2}, which is 103.9 ppm·m here.

Stage 1 · Which data (geotoolz-catalog → geotoolz-patcher)

TL;DR. Index the scene file, ask for the day’s slice over the scene, and get back one field the patcher can split.

Problem. A catalog row holds a footprint, a time interval and a path. A GeoSlice s=(b,I,r,crs)s = (b, I, r, \mathrm{crs}) is a request: bounds bb, a time interval II, a resolution rr and a CRS. field_for returns the rows that intersect ss, mosaicked onto its grid, as one RasterField.

1
2
3
4
5
6
7
8
9
10
11
# Shapes: c = 40 bands, H = W = 256 pixels
from geocatalog import GeoSlice, build, patch

cat = build.build_raster_catalog([path], filename_regex=r"scene_(?P<date>\d{8})",
                                 date_format="%Y%m%d")            # 1 row        today
# s = (b, I, r, crs): the scene's bounds, 15 July, 30 m, UTM 13N
slice_ = GeoSlice(bounds=(x0, y0 - H * res, x0 + W * res, y0),
                  interval=pd.Interval(day_start, day_end, closed="both"),
                  resolution=(30.0, 30.0), crs="EPSG:32613")     # today
field = patch.field_for(cat, slice_)          # rows ∩ s → RasterField (c, H, W)   today
# → RasterField, domain shape (40, 256, 256)

Catalog to field; nothing in this stage changes under the plan.

This is the seam Report 6 documents: the catalog imports geopatcher only inside geocatalog.patch, through the [patch] extra, which is why rule 4 holds today.

Stage 2 · Which piece, and the enhancement (geotoolz-patcher → geotoolz)

TL;DR. Cut the scene into overlapping chips, run a matched filter fitted on each chip, and blend the chips back into one enhancement map.

Problem. Chips are n=128n = 128 pixels on a side at stride s=64s = 64, so the scene of Nx=256N_x = 256 pixels has, by (2),

K=⌊Nx−ns⌋+1=3K = \left\lfloor \frac{N_x - n}{s} \right\rfloor + 1 = 3

anchors per axis and K2=9K^2 = 9 chips. On chip kk the filter fits a median μ^k\hat{\mu}_k and a Ledoit–Wolf shrinkage covariance Σ^k\hat{\Sigma}_k Ledoit & Wolf, 2004, then estimates the enhancement (3) Thompson et al., 2015 (derived in radtrax · 1, stage 5):

α^k(x)=(x−μ^k)⊤Σ^k−1tt⊤Σ^k−1t.\hat{\alpha}_k(x) = \frac{(x - \hat{\mu}_k)^\top \hat{\Sigma}_k^{-1} t}{t^\top \hat{\Sigma}_k^{-1} t} .

Each chip carries a separable Hann weight wk(p)=h(i) h(j)w_k(p) = h(i)\,h(j), with h(i)=sin⁡2 ⁣(π(i+12)/n)h(i) = \sin^2\!\big(\pi (i + \tfrac{1}{2}) / n\big) sampled at pixel centres, and the merged map is the weight-normalised sum over the chips K(p)\mathcal{K}(p) that cover pixel pp, (4):

α^(p)=∑k∈K(p)wk(p) α^k(p)∑k∈K(p)wk(p).\hat{\alpha}(p) = \frac{\sum_{k \in \mathcal{K}(p)} w_k(p)\, \hat{\alpha}_k(p)}{\sum_{k \in \mathcal{K}(p)} w_k(p)} .
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Shapes: c = 40, n = 128 chip side, K² = 9 chips
from geopatcher import SpatialPatcher, spatial
from geotoolz import matched_filter as mf
from geotoolz.patch_ops import ApplyToChips, GridSampler, MergePatches
from pipekit import Sequential

patcher = SpatialPatcher(
    geometry=spatial.geometry.Rectangular(size=(128, 128)),
    sampler=spatial.sampler.RegularStride(step=(64, 64)),
    window=spatial.window.Hann(),                    # h(i) = sin²(π (i + ½) / n)
    aggregation=spatial.aggregation.OverlapAdd(),
)                                                                    # today
pipe = Sequential([
    GridSampler(patcher=patcher),                    # (c, H, W) → 9 × (c, n, n)      today
    # α̂ₖ(x) = (x − μ̂ₖ)ᵀ Σ̂ₖ⁻¹ t / (tᵀ Σ̂ₖ⁻¹ t)
    ApplyToChips(operator=mf.MatchedFilter(target=t, fit_on_call=True)),
                                                     # 9 × (c, n, n) → 9 × (n, n)    today
    # α̂(p) = ∑ₖ wₖ(p) α̂ₖ(p) / ∑ₖ wₖ(p)
    MergePatches(aggregation=spatial.aggregation.OverlapAdd(),
                 domain=field.domain),               # 9 × (n, n) → (H, W)            today
])
alpha = pipe(field)                                  # GeoTensor (H, W), ppm·m         today
# → background std 107.2 ppm·m against σ_α = 103.9 (+3.2 %); peak 1588 against 1500
# → same CRS and transform as the scene; correlation with the truth 0.73

Split, filter and merge as one pipekit graph.

The same pipeline with an identity operator in place of the filter returns the scene with a maximum absolute error of 0.0, so the split and merge add nothing of their own. This stage is the payoff of rule 5: the patcher is carrier-agnostic, the filter is pure GeoTensor, and pipekit’s Sequential joins them.

Stage 3 · Mass and mask (geotoolz)

TL;DR. Turn the enhancement into column mass and keep the pixels that stand clear of the noise.

Problem. With MCH4=0.01604M_{\mathrm{CH_4}} = 0.01604 kg mol⁻¹ the molar mass of methane and Vm=0.024465V_m = 0.024465 m³ mol⁻¹ the molar volume of air at 298.15 K and 1 atm, the column mass is

ΔΩp=10−6 α^p MCH4Vm[kg m−2].\Delta\Omega_p = 10^{-6}\, \hat{\alpha}_p\, \frac{M_{\mathrm{CH_4}}}{V_m} \quad [\mathrm{kg\,m^{-2}}] .

The plume mask P\mathcal{P} keeps connected regions of at least 50 pixels where ΔΩp>3σ^\Delta\Omega_p > 3 \hat{\sigma}, and σ^\hat{\sigma} is the robust noise estimate 1.4826median⁡p∣ΔΩp−median⁡ΔΩ∣1.4826 \operatorname{median}_p |\Delta\Omega_p - \operatorname{median} \Delta\Omega|.

1
2
3
4
5
6
7
8
9
# Shapes: H = W = 256
from geotoolz import plume

# ΔΩ = 10⁻⁶ α̂ M_CH₄ / V_m
kg_m2 = plume.ColumnToMass(gas="CH4", units_in="ppm_m")(alpha)   # (H, W) → (H, W)  today
# σ̂ = 1.4826 · median |ΔΩ − median ΔΩ|
sigma_kg = 1.4826 * np.median(np.abs(kg_m2 - np.median(kg_m2)))  # scalar     user
mask = plume.PlumeMask(threshold=3 * sigma_kg, min_area_px=50)(kg_m2)  # (H, W) bool  today
# → σ̂ = 111.2 ppm·m equivalent; 1246 plume pixels, 3 outside the true 3σ_α region

Column mass and a 3σ̂ mask.

ColumnToMass matches the closed form (5) to 1e-12. Stage 3 uses only geotoolz, so nothing in it moves.

Stage 4 · Wind (xrtoolz → xrtoolz-core)

TL;DR. Take the 10 m wind from a reanalysis cube at the overpass and turn it into an effective wind for the plume.

Problem. u10u_{10} and v10v_{10} are the 10 m wind components on an ERA5-shaped grid of 24 hourly steps and 5 × 5 points, with u10=3+0.5sin⁡(2πh/24)u_{10} = 3 + 0.5 \sin(2\pi h / 24) m s⁻¹ and v10=4v_{10} = 4 m s⁻¹. The wind speed and the effective wind of Varon et al. Varon et al., 2018 are (6):

U10=u102+v102,Ueff=0.33 U10+0.45.U_{10} = \sqrt{u_{10}^2 + v_{10}^2} , \qquad U_{\mathrm{eff}} = 0.33\, U_{10} + 0.45 .
1
2
3
4
5
6
7
8
9
# Shapes: T = 24 hourly steps, 5 × 5 grid
from xrtoolz.atm import WindSpeed

# U₁₀ = √(u₁₀² + v₁₀²)
ws = WindSpeed(u="u10", v="v10", name="ws10")(era5)["ws10"]       # (T, 5, 5) → (T, 5, 5)  today
U10 = float(ws.interp(time=overpass, latitude=39.5, longitude=-105.5))  # → scalar  today
# U_eff = 0.33 U₁₀ + 0.45
U_eff = 0.33 * U10 + 0.45                                          # scalar     user
# → U₁₀ = 4.722 m s⁻¹, U_eff = 2.008 m s⁻¹

Wind speed on xarray, interpolated to the overpass.

WindSpeed writes CF attributes through apply_cf_attrs and the Variable registry, which it imports from xrtoolz-reader today. After C the same names come from xrtoolz-core and nothing in this block changes. A real run would download the cube with xrreader.CDSSource; it is built in memory here so the page needs no credentials. This UeffU_{\mathrm{eff}} is calibrated for a plume length L=AL = \sqrt{A}; stage 5 passes it to an operator whose length is the chord (see the note there).

Stage 5 · Emission rate (geotoolz)

TL;DR. Sum the mass inside the mask and divide by the time the wind takes to carry it the plume’s length.

Problem. geotoolz’s IMEEstimate sums the mass over the mask (Ap=900A_p = 900 m² per pixel) and returns Q=Ueff IME/ℓQ = U_{\mathrm{eff}}\,\mathrm{IME}/\ell, with ℓ\ell the mask’s longest axis (the chord) and σQ=0.5 Q\sigma_Q = 0.5\, Q at its default fraction. The method and its limits are derived in plumax · 1.

1
2
3
4
# Shapes: H = W = 256; U_eff scalar from stage 4
# IME = ∑_{p∈𝒫} ΔΩ_p A_p,  Q = U_eff IME / ℓ
est = plume.IMEEstimate(wind_speed=U_eff)(kg_m2, mask)   # (H, W), (H, W) → dict   today
# → IME 534.9 kg, ℓ 1651.1 m, Q 0.651 kg s⁻¹, σ_Q 0.325 kg s⁻¹

The emission rate from the GeoTensor and xarray stages together.

The script passes one float from the xarray lane to the GeoTensor lane; nothing imports across, which is rule 12. Within the mask the true IME is 584.6 kg, so the estimate is 8.5 % low. Fitting μ^k\hat{\mu}_k and Σ^k\hat{\Sigma}_k on chips that contain the plume absorbs part of the signal: with the true μ\mu and Σ\Sigma the same mask gives +1.7 % (the notebook runs both and asserts both). That is a retrieval choice, not a packaging one, and is out of scope here.

Stage 6 · The seams, as they stand (pipekit, xrtoolz, geotoolz-patcher)

TL;DR. Exercise the four places where today’s packages disagree; each becomes a workstream.

Problem. A Signature is a symbolic record of named dimensions and a dtype, sig=((d1,n1),…,(dr,nr);τ)\mathrm{sig} = ((d_1, n_1), \ldots, (d_r, n_r); \tau), that Sequential.summary threads through every step. A mean over axis 0 maps ((d1,n1),…,(dr,nr))↦((d2,n2),…,(dr,nr))((d_1, n_1), \ldots, (d_r, n_r)) \mapsto ((d_2, n_2), \ldots, (d_r, n_r)).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Shapes: T = 24, 5 × 5 grid
wind_mean = Sequential([WindSpeed(u="u10", v="v10", name="ws10"),
                        MeanScalar(axis=0)])                       # today
# (a) two Signature classes                                           → workstream A
wind_mean.summary(xrcore.Signature({"time": 24, "latitude": 5, "longitude": 5}))
# → ValueError: too many values to unpack (expected 2)               today
wind_mean.summary(pipekit.Signature(dims=(("time", 24), ("latitude", 5),
                                           ("longitude", 5))))
# → last step: Signature((latitude=5, longitude=5) float64)           today

# (b) the window dataset's stencil import                              → workstream D
import geopatcher.time
# → ModuleNotFoundError: No module named 'geopatcher.time'           today
from pipekit_train import XarrayWindowDataset
# → ImportError: "XarrayWindowDataset requires `geopatcher` … not published
#   on PyPI … `uv pip install -e ../geopatcher`"                      today

# (c) a pure-numpy stencil import loads the raster stack               → workstream D
import geopatcher.temporal.stencils   # sys.modules gains georeader, rasterio, shapely   today

# (d) base installs                                                    → workstreams C, F
importlib.metadata.requires("xrtoolz")   # includes xrtoolz-reader>=0.0.2       today
importlib.metadata.requires("geotoolz")  # includes pipekit>=0.0.2 (another project on PyPI)

The seams the workstreams fix, run against the pinned revisions.

The XarrayWindowDataset message is wrong as well as unlucky: geopatcher is installed, and the module it wants was renamed. Report 11 records the same break as its A5.

Current-state audit

Six overlaps need action; seven look like duplication and are not (Table 2; Figure 2 shows what the page’s sketches call). The audit read references/geostack/pipekit at ef3c17f, references/geostack/geotoolz at 088475e and references/geostack/xrtoolz at 308c7f2 on 14 October 2026.

A grid with one row per stage of the running example and per workstream, and one square per stack call in that row's sketches, coloured by its marker. Of 42 calls, 29 run today, 11 are proposed by this report's workstreams and 2 are code the reader writes.

Figure 2:Of the page’s 42 marked stack calls, 29 run today, 11 are proposed by this report’s workstreams and 2 are code the reader writes (how to read it: conventions).

Table 2:Overlaps across the three repositories.

SymbolsDefined inUsed byVerdict
Signaturepipekit/signature.py:29 (ordered pairs); xrcore/signature.py:64 (mapping)pipekit-array reduce.py; 18 constructions in 5 modules of xrtoolz and xrtoolz-einxSplit ownership. Two layouts; mixing them in one Sequential fails (stage 6a)
nested_config, callable_name, require_operatorpipekit/_base/operator.py:282, 293, 302geotoolz _src/config.py, augment/_src/operators.py; geopatcher integrations/pipekit.pySplit ownership. Private API used by two packages
JaxModelOp ×2, EquinoxModelOp, ModelOp ×2, SklearnModelOp, SklearnOp ×2pipekit_jax/model_op.py:42; pipekit_train/adapters/equinox.py:66; xrtoolz/inference/modelop.py:30, 227, 257; xrsklearn/_src/operator.py:32; geotoolz/learn/_src/operators.py:45, 469EquinoxModelOp at 13 sites in pipekit-train; SklearnModelOp at 4True duplicate. Three backends, eight classes, one name for two unrelated classes
Variable, REGISTRY, apply_cf_attrs, subset_bbox, subset_time, subset_wherexrreader/types/_src/ (2,341 lines; NumPy, pandas, xarray and the standard library)8 imports in 7 xrtoolz modules (atm, geo, viz)Split ownership. Vocabulary in the downloader; xrtoolz requires xrtoolz-reader
XarrayWindowDataset, _xreader.training_iteratorpipekit_train/xarray_window.py:134, _xreader.py:347pipekit-train usersSplit ownership. Imports geopatcher.time (xarray_window.py:35, _xreader.py:380), removed by geotoolz #431
open_emit_ch4_l2b, open_ghgsat_ch4_l2, open_tropomi_ch4_l2 · goes, himawari, carbonmapper readersxrreader/_src/local/openers/ (379 lines) · geoproducts/xrreader users · geoproducts usersSplit ownership. Two homes for mission readers, no rule between them (Report 3, question 7)
exact_quotient · count_stepsgeopatcher/_src/temporal/stencils.py:55 · geocatalog.gridtemporal stencils · GeoSlice alignmentHealthy. Named for what they do since #434; the draft’s divide_evenly clash is gone
obstore pool, files, cache, cog, hdfgeocloudgeocatalog staging, geopatcher CogField, geotoolz io.ReadHDF / ReadNetCDF, geoproducts HDF5Healthy. One pool per process, one home since #431
pipekit.Cache · geopatcher.run.PatchCache · geocloud.cache · xrreader cache_pathfour packagesHealthy. Operator results, patches across runs, local copies of remote objects, downloads: four jobs
geoproducts.goes.TrueColor · geotoolz.viz.TrueColorgoes/presets.py:152 · viz/_src/operators.py:124Healthy. The preset binds ABI channels to the geotoolz operator and imports it lazily
geotoolz.einx · xreinxboth lanesHealthy. Each speaks its carrier: transform survival on GeoTensor, dimension names on xarray
geocatalog Source · xrreader DataSourcegeocatalog.sources · xrreader/_src/base.pyHealthy. File inventories with footprints against server-side subsets
pipekit.Retry · geocatalog.utils.retry_transient_ioHealthy. Retry any operator in a graph against retry one remote read

Smaller findings:

Workstream A · pipekit owns the composition contract

Decision. There is one Signature, pipekit’s, and the helpers other packages import from pipekit are public (Table 3).

Table 3:Workstream A’s moves.

SymbolDestinationReason
Signature (ordered (name, size) pairs)stays in pipekit.signaturepositional operators (pipekit-array) need axis order
Signature(dims) accepting a mapping as well as pairs, Signature.from_sizes(mapping, dtype), .sizes (read-only mapping)pipekit.signature, newxrtoolz builds and compares signatures by name; a dict’s order is the axis order, so xrcore.Signature({"time": 24, …}) keeps its call semantics, not only its import
dtype canonicalisation without NumPy: a table of the accepted spellings, each mapped to the name np.dtype(x).name gives ("f8", "<f8", "float64", float → "float64"; "f4", "<f4" → "float32"; the integer, unsigned, bool and complex codes likewise, with any byte-order prefix); a dtype object gives its .name and a scalar type its __name__, both then looked up in the same table; anything else raises TypeError naming the inputpipekit.signaturexrcore’s one behaviour pipekit lacks; pipekit-core stays dependency-free, and gate A1 checks the table against np.dtype(x).name for every entry, so equal dtypes give equal signatures
xrcore.Signaturere-export of pipekit.Signaturefrom xrcore import Signature keeps working
nested_config, callable_namepipekit.configused by geotoolz and geopatcher
require_operatorpipekitused by geotoolz augment
  1. Widen pipekit’s class. Let the constructor take a mapping as well as ordered pairs (a mapping is converted to pairs in insertion order), and add from_sizes, sizes and dtype canonicalisation; dims stays ordered pairs. Existing xrcore.Signature({...}) calls keep working unchanged.

  2. Re-export it from xrcore. Replace xrcore/signature.py with the re-export, construct with Signature.from_sizes(ds.sizes), and compare with a.sizes == b.sizes where order should not matter.

  3. Publish the helpers. Add the three names to pipekit’s public surface and replace the three private import statements.

1
2
3
4
5
6
7
8
9
10
11
# Shapes: T = 24, 5 × 5 grid
# Today:
sig = xrcore.Signature({"time": 24, "latitude": 5, "longitude": 5})   # today
wind_mean.summary(sig)                       # → ValueError               today
# After:
# {d: n} in insertion order ↦ ((d₁, n₁), …, (d_r, n_r))
sig = xrcore.Signature({"time": 24, "latitude": 5,
                        "longitude": 5})     # the same call     proposed (A)
xrcore.Signature is pipekit.Signature        # → True               proposed (A)
sig == pipekit.Signature.from_sizes(sig.sizes)   # → True           proposed (A)
wind_mean.summary(sig)                       # last: (latitude=5, longitude=5)  proposed (A)

Stage 6a, before and after workstream A.

This revisits nothing in Report 2: its Signature stays, and only gains the mapping view Report 3 found missing.

Workstream B · One model-operator family

Decision. Each lane owns how its carrier becomes an array and back; each backend is wrapped once (Table 4).

Table 4:Workstream B’s moves.

SymbolDestinationReason
pipekit_jax.JaxModelOpstays: the one Equinox wrapperweight round-trip through the model registry
pipekit_train.adapters.equinox.EquinoxModelOpdeprecated alias of the above; the adapter returns JaxModelOpReport 11’s B1; it exists only so pipekit-train avoids pipekit-jax
pipekit-cycle in pipekit-jax’s basepipekit-jax[diffrax]only diffrax_model.py needs it, for RecursiveCheckpointAdjoint; this removes the reason for the twin
xrtoolz.inference.ModelOpstays: xarray marshalling for any callablemethod=None means “call the model”
xrtoolz.inference.JaxModelOp(model, *, jit=True, **kw)deprecated wrapper: warns, wraps model in jax.jit, imported lazily, when jit is true (today’s default and today’s implementation), and builds ModelOp(model, method=None, **kw)answers Report 3’s question 4; existing calls with jit= keep working for the deprecation period, and the wrapper adds no dependency: xrtoolz declares neither JAX nor Equinox, and Equinox stays out
xrtoolz.inference.SklearnModelOpdeprecated alias of xrsklearn.SklearnOpone xarray scikit-learn bridge
geotoolz.learn.ModelOp, SklearnOpstayGeoTensor marshalling
  1. Return JaxModelOp from the Equinox adapter, as Report 11’s B1 says, and alias EquinoxModelOp to it.

  2. Move pipekit-cycle into pipekit-jax’s [diffrax] extra, so pipekit-train’s [equinox] extra can depend on pipekit-jax.

  3. Alias the two xrtoolz wrappers and teach ModelOp method=None.

1
2
3
4
5
6
7
8
9
# Shapes: X (T, 5, 5, f = 4) features; f_θ : ℝᶠ → ℝᵏ, k = 1
# Today:
op = xrtoolz.inference.JaxModelOp(net, feature_dim="feature")     # today
# After:
model = pipekit_jax.JaxModelOp(net)          # serialize_weights()  today
# stack non-feature dims: (T, 5, 5, f) → (n = T·25, f);  ŷₙ = f_θ(xₙ): (n, k)
op = xrtoolz.inference.ModelOp(model, method=None, feature_dim="feature",
                               output_dim="output")             # proposed (B)
# → (T, 5, 5, output = 1)

A per-pixel Equinox emulator on the stage 4 cube, before and after.

Workstream C · xrtoolz-core owns the shared xarray vocabulary

Decision. The names every xarray operator speaks live in xrtoolz-core; xrtoolz-reader keeps its archives and readers (Table 5).

This corrects one point from the 7 October draft: xrtoolz does not import BBox or TimeRange from xrreader. It imports Variable, REGISTRY, apply_cf_attrs and the three subset functions.

Table 5:Workstream C’s moves.

SymbolDestinationReason
BBox, TimeRange, DepthRange, PressureLevelsxrcore.typesrequest vocabulary, shared with every reader
Variable, REGISTRY, resolve, registerxrcore.typesCF variables used by xrtoolz.atm, geo and viz
apply_cf_attrsxrcore.typesused by xrtoolz.atm
validate_dataset, validate_variable, ValidationReportxrcore.typesthe validators belong beside the registry they check against
subset_bbox, subset_time, subset_wherexrcore.typesused by xrtoolz.geo
Request, Station, StationCollectionstay in xrreader.typesreader-only
xrtoolz-reader in xrtoolz’s base dependenciesa [reader] extrathe base install stops pulling the archive code
xrtoolz.rs, xrtoolz.iceremovedempty; radiometry and indices are geotoolz’s (rule 12)
  1. Move the modules from xrreader/types/_src/ into xrcore/types/. They need NumPy, pandas, xarray and the standard library. pandas is in xrtoolz-core’s tree only through xarray today, so the move adds pandas to xrtoolz-core’s dependencies: a package declares what it imports (rule 8).

  2. Re-export from xrreader.types for the 0.x series.

  3. Repoint xrtoolz’s eight imports, move xrtoolz-reader to [reader], and delete the empty rs and ice packages.

1
2
3
4
5
# Today:
from xrreader.types import Variable, apply_cf_attrs, subset_bbox      # today
# After:
from xrcore.types import Variable, apply_cf_attrs, subset_bbox        # proposed (C)
from xrreader.types import Variable          # still works, same object      proposed (C)

Stage 4’s imports, before and after workstream C.

Workstream D · geotoolz-patcher owns windows for every carrier

Decision. Windowed sampling for training lives with the stencils that define it, in geotoolz-patcher, and nothing in pipekit imports a geo package (Table 6).

Table 6:Workstream D’s moves.

SymbolDestinationReason
from geopatcher.time import …from geopatcher.temporal.stencils import …D1: the module was renamed in #431
XarrayWindowDataset, _xreadergeopatcher.integrations.train, behind geotoolz-patcher[train] (pipekit-train, xarray, zarr, Grain)D2: removes the only pipekit → geo edge
from pipekit_train import XarrayWindowDatasetremoved, with no shim; the notebooks of Reports 11 and 15, which import it through a geopatcher.time alias, already try geopatcher.integrations.train firstit raises ImportError without that alias (stage 6), and a re-export would bring back the pipekit → geo edge D2 removes
the raster fields, reprojection, matched fields and the RasterDomain aliasimported on first use from the package rootD3: a stencil import should load NumPy only
xrpatcher in xrtoolz’s design docsgeopatcher’s XarrayField, GridSampler, MergePatchesD4: one patcher for both lanes

D1 · Fix the import

This is Report 11’s A5: import from geopatcher.temporal.stencils and make the error name the missing module. It ships first, because the dataset does not import at all today.

D2 · Move the dataset

XarrayWindowDataset subclasses pipekit-train’s TrainingDataset and rides its Grain path, so it needs pipekit-train; the stencils need geopatcher. The package that knows both is the one with the integration modules already: geopatcher has integrations/pipekit.py for operator graphs, and gains integrations/train.py. Two callers in this repository use the old path: the notebooks of Reports 11 and 15. Both import from geopatcher.integrations.train when it exists and fall back to pipekit-train otherwise, so they keep running on either side of D2.

D3 · A cheap stencil import

The package root imports the raster field, reprojection and matched fields, which import georeader, rasterio and shapely at module scope. Load them on first use from the root, as geocatalog already does for its extras, so geopatcher.temporal.stencils loads NumPy and nothing heavier. This is the lazy half of Report 7’s question 3; moving them behind a [raster] extra is the other half, and stays open.

D4 · The xarray lane uses geopatcher

Mark ob-1.1-xrpatcher-integration.md superseded, rewrite boundaries.md’s ownership map from this page’s rules, and change the interpolate/_src/downscale.py docstring to compose with GridSampler and MergePatches.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Shapes: T = 24 hourly steps; input window 6 h, target window 1 h
# Today:
from pipekit_train import XarrayWindowDataset      # → ImportError        today
# After:
from geopatcher.temporal.stencils import TimeStencil                  # today
from geopatcher.integrations.train import XarrayWindowDataset   # proposed (D2)
# x = (u, v)[t₀−5h … t₀]: (2, 6, 5, 5);  y = (u, v)[t₀+1h]: (2, 1, 5, 5)
ds = XarrayWindowDataset(
    source={"input": era5, "target": era5},
    stencils={"input": TimeStencil(start="-5h", stop="0h", step="1h", closed="both"),
              "target": TimeStencil(start="1h", stop="1h", step="0h", closed="both")},
    store_version="stage4-wind", split_guard=0,
)                                                               # proposed (D2)
x, y = ds[0]                         # (2, 6, 5, 5), (2, 1, 5, 5)  proposed (D2)
# valid origins: |𝒪| = T − a₋ − a₊ = 24 − 5 − 1 = 18         today (stencils)

Training windows from the stage 4 cube, before and after workstream D.

The origin count (7) follows from the stencils: with a−=5a_- = 5 steps of history and a+=1a_+ = 1 step of lead, an origin t0t_0 is valid only when both windows fit inside the T=24T = 24 steps, which today’s valid_origin_points confirms on the stage 4 cube:

∣O∣=T−a−−a+=18.|\mathcal{O}| = T - a_- - a_+ = 18 .

Workstream E · A mission reader lives with its output carrier

Decision. A reader whose output is a georeferenced raster (an affine transform and a CRS) belongs to geotoolz-products; one whose output is a swath, 2-D latitude and longitude, profiles or stations belongs to xrtoolz-reader.

This answers Report 3’s question 7: the TROPOMI reader stays on the xarray side (Table 7).

Table 7:Workstream E’s placements.

ReaderOutputLives in
GOES-R ABI, Himawari AHI, Carbon Mapper, toy sensorgridded GeoTensorgeotoolz-products (today)
TROPOMI CH₄ L2(scanline, ground_pixel) swathxrtoolz-reader (today)
GHGSat CH₄ L2(y, x) with 2-D latitude and longitudexrtoolz-reader (today)
EMIT L2B CH₄ enhancement, NetCDF container(time, y, x) with 2-D latitude and longitudexrtoolz-reader (today)
EMIT L2B CH₄ enhancement, distributed GeoTIFFgridded GeoTensorgeotoolz-products, new: xrreader’s opener declines this format
  1. Write the rule into both packages’ contributing guides and AGENTS.md.

  2. Add the EMIT L2B GeoTIFF reader to geotoolz-products, the one format the rule assigns there that neither package reads today.

  3. Index both packages’ subpackages in the capability index, so a mission name read in two places is visible (Report 3).

1
2
3
# Shapes: H × W ortho grid
enh = geoproducts.emit.Reader(tif_path).read()        # GeoTensor (H, W)   proposed (E)
ds = xrreader.open_emit_ch4_l2b(nc_path)               # Dataset (time, y, x)  today

One EMIT plume through each reader, after workstream E.

Workstream F · Packaging and release

Decision. The stack publishes under names it owns, pins itself by version range, and every repository tests its own packaging.

F1 · Names

pipekit publishes as pipekit-core and keeps import pipekit. This revisits Report 2’s settled question 1: the import name stays pipekit, but the distribution cannot, because pipekit on PyPI belongs to another project. The same decision serves the GeoML stack, which also depends on pipekit. Answering Report 6 and Report 7: the public names are geotoolz-catalog and geotoolz-patcher, as the monorepo already builds them.

Recommended: pipekit-core. Alternative: a new import name as well, which renames every import in three repositories for no functional gain.

F2 · Pins and floors

  1. Replace git pins with version ranges once a tier is on PyPI, and drop both workspace roots’ pipekit entries from [tool.uv.sources].

  2. Raise pipekit-train’s zarr floor to zarr>=3.0 in its cache, s3, gcs and xreader extras.

  3. Fix the xrtoolz hint: add oceanography = ["gsw"] or change it to pip install gsw.

F3 · Packaging tests

Copy geotoolz’s tests/test_packaging.py, which fails when a module-level import is missing from [project.dependencies], and its base-install CI job into every xrtoolz and pipekit package, and the test into geotoolz-cloud and geotoolz-products. That test catches undeclared module-level imports only, so two more checks cover the faults F2 fixes:

  1. A minimum-versions job installs each extra at its floors (uv pip install --resolution lowest-direct) and runs a test that calls the code path the extra exists for. For pipekit-train’s cache extra that is CachedDataset with an fsspec URL as its cache directory (memory://…, so no cloud is needed), which opens the store through zarr.storage.FsspecStore (dataset.py:478) and fails on today’s zarr>=2.18 floor; a local path never reaches that line.

  2. An install-hint test collects every pip install <dist>[<extra>] string in a package’s source and checks that <extra> is declared in its [project.optional-dependencies], which fails on today’s xrtoolz[oceanography] hint.

F4 · Release in tier order

Table 8 orders the releases by base dependency.

Table 8:Release order, by base dependencies. An extra may name a later tier; it resolves once that tier is published, and gate F4 checks the whole set.

ReleaseDistributionsBase dependencies in the stack
1pipekit-core, geotoolz-cloud, geotoolz-catalog, geotoolz-patcher, geotoolz-products, xrtoolz-gradnone
2pipekit-array, -cycle, -evaluate, -experiment, -jax, -train, xrtoolz-core, geotoolzpipekit-core (pipekit-jax after B)
3xrtoolz-einx, xrtoolz-sklearn, xrtoolz-readerxrtoolz-core (xrtoolz-reader after C)
4xrtoolzthe xrtoolz family

F5 · One stack check

A scheduled job installs all 18 distributions from PyPI into one fresh environment, with geotoolz-patcher[train] (which brings pipekit-train’s Grain and zarr extras), and runs each package’s fast tests and this page’s notebook with REPORT16_STATE=target. In that mode the notebook checks the seams of stage 6 against the realigned stack (one Signature, the window dataset importing from geopatcher and building 18 windows of the right shapes from stage 4’s cube, a NumPy-only stencil import, no reader in xrtoolz’s base install, and none of the 18 distributions still requiring pipekit by name, which after F1 is another project that pip would install beside pipekit-core without a conflict); by default it checks them as they stand today, so the page’s stage 6 stays reproducible at the pinned revisions.

Testing gates

Each workstream merges only when its gates pass (Table 9); the K gates are checks the code passes today and must keep passing.

Table 9:Gates for the proposals and for what ships.

GateWorkstreamPasses when
A1 One SignatureAxrcore.Signature is pipekit.Signature, and stage 6a’s summary with a signature built by today’s call, xrcore.Signature({"time": 24, "latitude": 5, "longitude": 5}), ends in (latitude=5, longitude=5) (today ValueError); and for every spelling in the dtype table ("f8", "<f4", float, "i4", "?", …), Signature.from_sizes(sizes, dtype=x).dtype == np.dtype(x).name, with NumPy used only by the test
A2 Signature round-tripAevery existing pipekit, pipekit-array and xrtoolz signature test passes against the merged class, with 0 failures
A3 No private importsAgrep -r "pipekit\._" over geotoolz returns 0 lines (today 3)
B1 One Equinox wrapperBpipekit-train’s tests pass with the adapter returning JaxModelOp, and pip install pipekit-jax without extras installs no pipekit-cycle
B2 Alias parityBeach deprecated alias returns outputs equal to its replacement to 0 on xrtoolz’s inference tests and emits exactly one DeprecationWarning; JaxModelOp(net, feature_dim="feature", jit=True) and jit=False both construct and agree to 0, in an environment with JAX and without Equinox
C1 Reader-free xrtoolzCimportlib.metadata.requires("xrtoolz") lists no xrtoolz-reader outside [reader] (today it does), and import xrtoolz succeeds without it
C2 Vocabulary shimsCfor every name the move table sends to xrcore.types (BBox, TimeRange, DepthRange, PressureLevels, Variable, REGISTRY, resolve, register, apply_cf_attrs, validate_dataset, validate_variable, ValidationReport, subset_bbox, subset_time, subset_where), getattr(xrreader.types, n) is getattr(xrcore.types, n), so old imports and isinstance checks keep working
D1 Window importDXarrayWindowDataset imports with the pinned geopatcher and no shim (today ImportError); Report 11’s stage 8 pairs satisfy y=Mx−1y = M x_{-1} to 1e-5
D2 No upward edgesDgrepping every pipekit package for geopatcher, geocatalog, geotoolz or xrcore returns 0 lines (today 2 files); the dataset’s content_hash on stage 4’s cube is identical before and after the move
D3 Cheap stencil importDin a fresh interpreter, import geopatcher.temporal.stencils puts none of georeader, rasterio, shapely, pyproj, geopandas, xarray, pandas, scipy, dask or zarr in sys.modules (today the first three)
E1 Carrier ruleEthe capability index lists no mission name under both geoproducts and xrreader except EMIT L2B, whose GeoTIFF and NetCDF containers are different carriers by design (stage table above); and those two EMIT readers agree on a fixture’s enhancement to 1e-6
F1 NamesFpipekit-core, geotoolz-cloud, geotoolz-products, geotoolz-catalog and geotoolz-patcher resolve to this stack on PyPI
F2 One environmentFgeotoolz and xrtoolz install editable into one environment without --no-sources (today a URL conflict)
F3 Packaging testsFxrtoolz and pipekit run test_packaging.py and a base-install job; the minimum-versions job fails on today’s zarr>=2.18 floor at FsspecStore and the install-hint test fails on today’s [oceanography] hint, and both pass after F2
F4 Clean installFall 18 distributions install from PyPI into one fresh environment with no git URLs and no resolver conflict
F5 Stack checkFthe scheduled job passes on the latest release of every distribution, with the notebook’s REPORT16_STATE=target seam checks
K1 Noise floor (existing)—stage 2: the background standard deviation of α^\hat{\alpha} is within 5 % of σα=(t⊤Σ−1t)−1/2\sigma_\alpha = (t^\top \Sigma^{-1} t)^{-1/2}. Passes today: 107.2 against 103.9, +3.2 %
K2 Split and merge (existing)—stage 2: the identity operator through GridSampler, ApplyToChips and MergePatches returns the scene to 1e-5. Passes today: 0.0
K3 Column mass (existing)—stage 3: ColumnToMass equals (5) to 1e-12. Passes today
K4 Emission rate (existing)—stage 5: IMEEstimate’s rate equals Ueff IME/ℓU_{\mathrm{eff}}\,\mathrm{IME}/\ell to 1e-12. Passes today

Sequencing and risks

The name and the contract go first: every later change either ships under the new name or touches operators that carry signatures. B, C, D and E are independent of each other and run side by side; publishing waits for all of them (Figure 3).

Phase 1: F1 publish as pipekit-core and A one Signature with public helpers, then gates F1, A1, A2, A3. Phase 2: B model operators, C vocabulary into xrtoolz-core, D datasets into the patcher, E the carrier rule for mission readers, side by side, then gates B1, B2, C1, C2, D1, D2, D3, E1. Phase 3: F2 to F5, pins and floors, packaging tests, publishing in tier order and the stack check, then their gates.

Figure 3:The name and the contract first, then four independent moves, then the release.

Open questions

  1. Does geoproducts get an xarray output? A to_xarray on its readers would let the xarray lane use gridded missions without a second reader, at the cost of xarray in geotoolz-products’ base.

  2. Should geotoolz-cloud’s base drop georeader? It needs georeader only for the GeoTensor that cog and hdf return; an index-only or bytes-only install would move those modules behind an extra, as Report 7 asks for the patcher.

  3. Does xrtoolz-reader use geotoolz-cloud for its downloads? Its cache and the cloud package’s cache keep local copies by different keys (request against URI). Sharing one would cross the lane rule at tier 2, which the tiers allow.

Upcoming work

Table 10 lists the follow-ups outside the workstreams.

Table 10:Follow-ups outside the workstreams.

Follow-upWhereWhy
Point the 7 October draft at this pagethe “Geo data stack” Claude docthis page is the source of truth
Rewrite boundaries.md from this page’s ownership rulesxrtoolz docs/design/it predates pipekit and geopatcher (D4)
Fix the misleading XarrayWindowDataset error message in the same PR as D1pipekit-train xarray_window.py:40geopatcher is installed; the module was renamed
Index geoproducts, geocloud and the xrtoolz subpackagesgeostack scripts/capabilities.pyE1 and the JaxModelOp clash need them (Report 3)

References

Citations

The matched filter follows Thompson et al. Thompson et al., 2015, its covariance the shrinkage estimator of Ledoit and Wolf Ledoit & Wolf, 2004, and the effective wind the calibration of Varon et al. Varon et al., 2018.

Sources

References
  1. Ledoit, O., & Wolf, M. (2004). A well-conditioned estimator for large-dimensional covariance matrices. Journal of Multivariate Analysis, 88(2), 365–411. 10.1016/S0047-259X(03)00096-4
  2. Thompson, D. R., Leifer, I., Bovensmann, H., Eastwood, M., Fladeland, M., Frankenberg, C., Gerilowski, K., Green, R. O., Kratwurst, S., Krings, T., Luna, B., & Thorpe, A. K. (2015). Real-time remote detection and measurement for airborne imaging spectroscopy: a case study with methane. Atmospheric Measurement Techniques, 8(10), 4383–4397. 10.5194/amt-8-4383-2015
  3. Varon, D. J., Jacob, D. J., McKeever, J., Jervis, D., Durak, B. O. A., Xia, Y., & Huang, Y. (2018). Quantifying methane point sources from fine-scale satellite observations of atmospheric methane plumes. Atmospheric Measurement Techniques, 11(10), 5673–5686. 10.5194/amt-11-5673-2018