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.

geopatcher

Report 7 · The four-axis patcher, as scoped and as built

Abstract

geopatcher splits a geospatial field into local patches, runs an operator on each one, and stitches the outputs back into a global result. It does this along four independently composable axes: geometry, sampler, window and aggregation. This report scoped it as a standalone package extracted from geotoolz, with a framework-free core and an optional pipekit bridge. It has been built, and it is now the largest package in the ecosystem. Most of the design held: the four axes, the Field and Domain Protocols, the extras-gated adapters, and the pipekit bridge in integrations/. Three things did not: the dependency-light core, the class names (the axis prefixes are gone), and the geotoolz migration. Each is carried to the open questions at the end.

Keywords:geopatcherpatchingsliding windowoverlap-addField protocol

Where geopatcher sits

The repo’s README calls geopatcher the locality layer between catalogs and operators. It answers a single question: “what slice of the data does my operator see at once, and how do local outputs become a global field?”

                georeader  (GeoTensor, GeoData, readers)
                    ▲
       ┌────────────┼────────────┐
  geocatalog     pipekit     geopatcher ──┐
       ▲            ▲            ▲        │ optional [pipekit] extra
       │            └──── geopatcher.integrations.pipekit
       │                         ▲          (GridSampler, ApplyToChips, Stitch)
       └────────────┬────────────┘
              geotoolz · xrtoolz

The ecosystem as scoped.

The scoping report’s reading of this picture holds: geopatcher is a peer of geocatalog and pipekit, it never imports geocatalog (geocatalog’s CatalogDomain satisfies its Domain Protocol instead), and pipekit appears only in one 154-line integrations.pipekit module behind the [pipekit] extra. The stack-wide rules behind that layering are in Report 16.

The four-axis framework

The four axes

Each axis is a strategy object, a small dataclass with a single job, that the patcher composes at construction. The repo’s rule is that anything you can swap without touching the other three is on its own axis (Figure 1 draws each one).

Four panels. Geometry, the shape of the neighbourhood, shown as an 8 by 8 rectangle and a disk of radius 5. Sampler, where anchors go, shown as regular-stride, jittered and random points. Window, per-cell weights, shown as boxcar, Hann, Tukey and Gaussian curves. Aggregation, local outputs to a global field, shown as boxes for OverlapAdd, Mean, WeightedSum, InvVarWMean, HardVote and ApproxQuantile.

Figure 1:The four orthogonal axes. From the geopatcher repo.

Table 1:The four axes and their shipped classes at 0.10.2, spatial and temporal.

AxisThe question it answersSpatial (geopatcher.spatial.*)Temporal (geopatcher.temporal.*)
Geometrywhat shape is the neighbourhood?Rectangular, SphericalCap, KNNGraph, RadiusGraph, PolygonIntersectionFixedLookback, LookbackHorizon, MultiScale, PhaseWindow, StencilGeometry
Samplerwhere do the anchors go?RegularStride, JitteredStride, Random, PoissonDisk, Explicit, ExplicitCoords, AlongTrackRegularStride, Random, Explicit, StencilSampler
Windowhow is each cell weighted?Boxcar, Hann, Tukey, Gaussian, CustomCausalBoxcar, ExponentialDecay, TaperedTukey, Periodic
Aggregationhow do local outputs become global?21 classes: OverlapAdd, Mean, Sum, Max, Min, Median, Mode, Variance, MeanStd, MinMax, WeightedSum, InvVarWeightedMean, HardVote, SoftVote, ByIndex, Learned, ApproxQuantile, ApproxCardinality, ApproxMode, StreamingHistogram, ReservoirMean, HierarchicalCombine, Fold, Forecast

Table 1 lists them: 38 spatial and 17 temporal classes, plus Stencil, TimeStencil and Closed for coordinate-space temporal stencils. The scoping report counted “60+”; the families, not the count, are what matters. The spatial aggregation family is where the research-grade work sits. Overlap-add with feathered windows, inverse-variance weighting, streaming histograms and approximate quantiles are the merge strategies that real sliding-window inference needs.

The patch lifecycle

A run touches each axis in turn. The sampler places anchors, the geometry turns each anchor into an index window, the field reads the window, the operator transforms the patch, and the aggregation merges the outputs, using the window’s weights (Figure 2):

Five panels from left to right. A 32 by 32 raster field; the same field split into 8 by 8 anchors; one 8 by 8 patch; the patch after a per-patch operator; and the merged global output. Arrows between them are labelled patcher.split, iterate, operator and patcher.merge.

Figure 2:Field → split → patches → operator → merge → output. From the geopatcher repo.

Each Patch carries:

split is an iterator by design (the repo’s ADR-001), so a field larger than memory streams patch by patch, and list(…) gives the eager case.

Overlap-add, the canonical merge

The aggregation that matters most for inference is overlap-add. Let patch pp cover the cells Ωp\Omega_p, carry window weights wp(x)w_p(x), and produce operator output y^p(x)\hat y_p(x) for each cell x∈Ωpx \in \Omega_p. The stitched field is the weight-normalised sum over every patch that covers the cell:

y(x)=∑p:x∈Ωpwp(x)y^p(x)∑p:x∈Ωpwp(x).y(x) = \frac{\sum_{p : x \in \Omega_p} w_p(x) \hat{y}_p(x)}{\sum_{p : x \in \Omega_p} w_p(x)} .

A feathered window such as Hann tapers each patch towards its edges, so seams between patches disappear. The denominator in (1) makes the reconstruction exact wherever patches overlap: if every y^p\hat y_p is the identity, then y(x)y(x) is the input. Figure 3 shows the Hann weights, their sum and the flat reconstruction.

Three stacked plots against position. First, three overlapping Hann windows centred at 0, 24 and 48. Second, their accumulated weights, the denominator, which is close to one across the interior. Third, the normalised reconstruction, flat at one on the interior.

Figure 3:Overlap-add with feathered (Hann) windows: the weights, their sum (the denominator in (1)), and the flat normalised reconstruction. From the geopatcher repo.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import numpy as np
import rasterio
from georeader.geotensor import GeoTensor

import geopatcher as gp
from geopatcher import spatial

# 1. Wrap any array as a Field.
ramp = np.linspace(0, 1, 64)
arr = np.outer(ramp, ramp).astype(np.float32)                 # (64, 64)
field = gp.RasterField(
    GeoTensor(values=arr, transform=rasterio.Affine.identity(), crs="EPSG:32630")
)

# 2. Compose the four axes.
patcher = gp.SpatialPatcher(
    geometry=spatial.geometry.Rectangular(size=(16, 16)),     # Ωₚ: 16×16 cells
    sampler=spatial.sampler.RegularStride(step=(8, 8)),       # 50 % overlap
    window=spatial.window.Hann(),                             # wₚ(x): feathered
    aggregation=spatial.aggregation.OverlapAdd(),             # y = Σ wₚŷₚ / Σ wₚ
)

# 3. Split → operate per patch → merge.
out = [p.with_data(np.asarray(p.data) * 2.0)                  # ŷₚ = 2 · patch
       for p in patcher.split(field)]                         # 49 patches, 16×16
stitched = patcher.merge(out, field.domain)                   # → (64, 64) float64
np.abs(stitched - 2 * arr).max()                              # → 4.4e-16
patcher.merge_to_field(out, field)                            # → GeoTensor (64, 64)

The README quickstart in current names at geopatcher 0.10.2, from a real run.

With no overlap (stride 16, a boxcar window), both OverlapAdd and Mean reconstruct the field exactly (np.array_equal is True). The Hann case is exact to round-off even at the outer edge, because the outermost Hann weights are small but not zero. One detail to know: merge returns float64 even for a float32 field.

Boundary policies

When an anchor sits close enough to the edge that its neighbourhood would overflow the field, the geometry’s boundary argument decides what happens (Figure 4; counts on a real run in Table 2):

Four panels. A 6 by 6 patch anchored at (8, 8) overflows the field. Under drop, the default, that anchor is never emitted. Under pad, the patch is the full 6 by 6, with nodata in the overflow. Under shrink, the patch is cropped to the field and its weights crop to match.

Figure 4:What happens at the edge of the field. From the geopatcher repo.

Table 2:Boundary policies on a 64×64 field with 16×16 patches at stride 10, from a real run.

boundary=BehaviourPatchesUncovered cells
"drop" (default)overflowing anchors are never emitted25960, left as NaN
"pad"edge anchors emitted; the read is padded to full size with nodata360
"shrink"edge anchors emitted; the patch and its weights are cropped36 (sizes 14×14 to 16×16)0
"reflect"edge anchors emitted; the overflow is mirrored360
"raise"ValueError on the first overflowing window——

The default deserves a warning of its own. With "drop", a stride that does not divide the field leaves a strip along the far edges that no patch covers, and merge returns NaN there. The repo’s concepts page still lists "reflect" as a planned follow-up; it landed in the standalone repo’s last commits in July, just before the merge.

The Field and Domain Protocols

The whole framework rests on two small Protocols, and the scoping report was right to call them the key abstraction:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@runtime_checkable
class Domain(Protocol):
    """Metadata only: bounds and CRS, no I/O."""
    @property
    def crs(self) -> Any: ...
    @property
    def bounds(self) -> Any: ...

@runtime_checkable
class Field(Protocol):
    """A substrate the patcher reads patches out of."""
    @property
    def domain(self) -> Domain: ...
    def select(self, indexer: Any) -> Any: ...       # read one patch's data
    def with_data(self, array: Any) -> Any: ...      # rebuild around new data

The Protocols at geopatcher 0.10.2, simplified from _src/protocols.py.

A field may also provide placeholder(indexer, data), which is duck-typed and optional and is used by on_error="mask". AsyncField has the same shape with an awaitable select. Because the patcher only talks to these Protocols, it does not care what carrier sits underneath: you wrap the data once, at the boundary, and the four-axis machinery does the rest. Table 3 lists the adapters that ship.

Table 3:The Field adapters at 0.10.2, against the scoping report.

AdapterWrapsWhere, and the extra it needsIn the report
RasterFieldgeoreader GeoData (a reader or a GeoTensor)root; base installas planned ([raster])
AsyncRasterFieldgeoreader AsyncGeoDatageopatcher.fields; baseas planned
ReprojectingRasterFielda raster read into another CRSgeopatcher.fields; basenew
XarrayFieldxarray.DataArraygeopatcher.fields; [grid]as planned
RioXarrayFielda rioxarray DataArraygeopatcher.fields; [xarray-raster]as planned
XvecFieldan xvec point cubegeopatcher.fields; [point]as planned
GeoPandasFieldgeopandas.GeoDataFramegeopatcher.fields; [vector]as planned
DaskFielda dask-backed arraygeopatcher.fields; [dask]new
CogFielda cloud-optimised GeoTIFF, through geocloudgeopatcher.fields; [cog]new
MatchedFieldseveral co-registered fieldsgeopatcher.matchednew

The extras-gated adapters import lazily: geopatcher.fields.XarrayField resolves on first access, and a missing extra produces a message naming the install command. The domains (RasterDomain, GridDomain, VectorDomain, PointDomain) live in geopatcher.fields; RasterDomain is georeader’s GeoDataBase.

Public surface

Table 4 lists the public namespaces.

Table 4:geopatcher’s public namespaces at 0.10.2.

NamespaceHolds
geopatcher (root)Field, AsyncField, Domain; Patch, TemporalPatch, SpatioTemporalPatch; SpatialPatcher, AsyncSpatialPatcher, TemporalPatcher, SpatioTemporalPatcher; RasterField
geopatcher.spatialgeometry, sampler, window, aggregation, each with its classes (see Table 1)
geopatcher.temporalthe same four modules, plus stencils (Stencil, TimeStencil, Closed, helpers)
geopatcher.fieldsthe adapters and the four domains
geopatcher.matchedMatchedField, MatchedPatch and the matched patchers
geopatcher.runparallel_map, prefetch_iterable, batch_split, unbatch, stack_patches, PatchCache, IndexedPatchView, to_delayed, to_dask_bag
geopatcher.observePatcherHook, PatchJournal, PatchErrorRecord, OnErrorPolicy, strict mode
geopatcher.config{"class", "config"} envelopes: axis_envelope, from_config, config_from_fields
geopatcher.integrations.pipekitGridSampler, ApplyToChips, MergePatches (Section ??)

A patcher does more than split and merge now. SpatialPatcher also has:

SpatioTemporalPatcher(spatial, temporal, coupling="product" | "coupled") composes the spatial and temporal patchers.

What shipped beyond the scope

Table 5 lists what the patcher gained beyond the scoping report.

Table 5:Shipped features the scoping report did not cover. Most are documented in the repo’s recipes and design decisions.

FeatureWhereWhat it does
Parallel runnersrun.parallel_map(patcher, field, operator, n_workers=8, backend="thread")independent per-patch jobs, on threads or processes (forkserver)
Streaming overlap-addspatial.aggregation.OverlapAdd(streaming=True, target_path=…, chunks=…)the accumulator on disk as zarr (or a COG), for outputs over a terabyte (ADR-002)
Journal and resumeobserve.PatchJournal, split(journal=…)record finished anchors; resume a killed run
Error policieson_error=, patcher.errorsskip, mask or retry failing patches instead of aborting
Hooksobserve.PatcherHookcallbacks at split start, per patch, on error and at the end
Read cacherun.PatchCache, split(cache=…)an LRU cache of reads, bounded by bytes, across runs
Random accessrun.IndexedPatchViewa Sequence view over a patcher, for training data loaders (ADR-005)
Async prefetchsplit(prefetch=…, max_in_flight=…, max_in_flight_bytes=…)overlap remote reads with compute, with backpressure
Mixed-CRS patchingspatial.sampler.ExplicitCoords, AlongTrack, fields.ReprojectingRasterFieldanchors in lon/lat over UTM imagery; along a satellite ground track
Matched fieldsgeopatcher.matchedpatch co-registered fields together (ADR-003)
Temporal stencilstemporal.stencilscoordinate-space time windows such as "-9h" (ADR-004)
Config envelopesgeopatcher.configserialise and rebuild patchers from {"class", "config"}
Dask and JAXrun.to_dask_bag, [dask], [jax]distributed and accelerator back-ends

The pipekit integration

The scoping report isolated the only pipekit-coupled code in geotoolz’s patcher (_src/ops.py, three operator wrappers) in geopatcher.integrations.pipekit, behind a [pipekit] extra. That is what shipped, with one rename (Table 6):

Table 6:The pipekit bridge, scoped and shipped.

ScopedShipped at 0.10.2What it does
GridSampler(patcher)GridSampler(patcher=…)yield patches from a field, as an operator
ApplyToChips(op)ApplyToChips(operator=…)apply an operator to every patch in an iterable
Stitch(aggregation)MergePatches(aggregation=…, domain=…) (renamed in 0.10.0)merge patches back into a field

The three compose into a sliding-window inference pipeline:

1
2
3
4
5
6
7
8
9
10
import pipekit as pk
from geopatcher.integrations.pipekit import ApplyToChips, GridSampler, MergePatches

pipeline = pk.Sequential([
    GridSampler(patcher=patcher),                          # Field → Iterator[Patch]
    ApplyToChips(operator=model_op),                       # ŷₚ = model(patch)
    MergePatches(aggregation=spatial.aggregation.OverlapAdd(),
                 domain=field.domain),                     # y = Σ wₚŷₚ / Σ wₚ
])
result = pipeline(field)

Sliding-window inference as a pipekit pipeline (a sketch; needs the [pipekit] extra).

The scoping report’s three reasons for keeping the bridge in geopatcher rather than geotoolz held:

geotoolz’s patch_ops re-exports the same three objects.

Dependencies and extras

The scoping report’s core was numpy and scipy only: no field adapters, no pipekit. A user would wrap their own data in a Field and bring their own adapter. Table 7 and Table 8 set that against what shipped.

Table 7:The base install, scoped, as the README describes it, and shipped.

ScopedREADME (0.0.6)Shipped at 0.10.2
numpy, scipy“base — RasterField only”numpy, scipy, georeader-spaceml, rasterio, pyproj, typing-extensions

Table 8:The optional extras, scoped and shipped.

ScopedShipped
raster = [georeader]none: georeader is in the base
xarray-raster = [xarray, rioxarray]xarray-raster = [rioxarray, xarray]
grid = [xarray]grid = [xarray]
point = [xvec, xarray]point = [xvec>=0.4]
vector = [geopandas, shapely]vector = [geopandas, shapely]
pipekit = [pipekit]pipekit = [pipekit>=0.0.2]
allpatch-full (everything but pipekit)
—streaming = [zarr>=3.0], dask, jax, cog = [geotoolz-cloud[cog]]

No geocatalog dependency. This holds exactly. You can patch your own data without geocatalog, and find files with geocatalog without geopatcher. When you want both, geocatalog.patch.field_for hands a catalog to geopatcher as a RasterField, and CatalogDomain satisfies Domain (Report 6). Neither package imports the other at module level.

Migration from geotoolz

The scoping report planned to:

Table 9 gives each step’s status.

Table 9:The migration steps at the pinned revisions.

StepStatus
Patcher code leaves geotoolz for its own packagedone: geopatcher, now geotoolz-patcher in the same monorepo
_src/ops.py becomes geopatcher.integrations.pipekitdone
geotoolz re-exports the patcher (gz.SpatialPatcher, gz.patch)not done; geotoolz has no patcher names at its root
A geotoolz patch_ops moduledone, differently: it re-exports GridSampler, ApplyToChips and MergePatches, and adds TriangularWindow, StratifiedSample and BalancedSampler (its [patch] extra)
ReadFromCatalogAsPatches, GeoTensorChipFilternot built
xrtoolz adopts geopatchernot done; xrtoolz mentions only the older xrpatcher, in docstrings

The scoping report’s cross-library examples still work in current names:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import xarray as xr
import geopatcher as gp
from geopatcher import spatial
from geopatcher.fields import XarrayField

field = XarrayField(xr.open_dataset("reanalysis.nc")["sst"])
patcher = gp.SpatialPatcher(
    geometry=spatial.geometry.Rectangular(size=(32, 32)),
    sampler=spatial.sampler.RegularStride(step=(16, 16)),
    window=spatial.window.Boxcar(),
    aggregation=spatial.aggregation.Mean(),
)
for patch in patcher.split(field):     # patch.data is an xr.DataArray
    ...

Patching an xarray grid with no geotoolz in sight (a sketch; needs the [grid] extra).

The scoping report’s JAX example also holds as a pattern: the patcher iterates in Python, the patches are stacked, and a jitted, vmapped function processes them all at once. run.batch_split and run.stack_patches now give that stacking step a name. A pipeline built from pipekit operators still cannot be jitted (Report 3).

Monorepo, release and timing

The scoping report picked a single “pipekit-ecosystem” monorepo for the patcher. Its reasons were especially strong here: six field adapters across five ecosystems, 60-odd classes to test across all of them, and a cross-package bridge. As with geocatalog, the patcher landed in the geotoolz monorepo instead, beside geocatalog and geocloud, with per-package releases (geotoolz-patcher-v0.10.2). There are 60 test modules (about 19,000 lines), including a pipekit integration test. Table 10 gives the release record.

Table 10:geopatcher’s history.

WhenWhat
May – July 2026standalone geopatcher repo, 0.0.1–0.0.6: the four axes, the fields, async, streaming overlap-add, journals, error policies, boundary modes; its last commits add reflect, CRS-aware patching and PatchCache
13 July 2026geopatcher and geocatalog merge into the geotoolz monorepo (geotoolz-patcher 0.1.0); the standalone repo is later archived
2 – 8 October 20260.3.0 to 0.10.0, with breaking changes: object storage split into geocloud; organised by task (run, observe); prefixes dropped (0.9.0); Stitch renamed MergePatches (0.10.0)
12 October 20260.10.2, the pinned revision

The estimate was a week and a half of focused work, in one coordinated v1.0 with the pipekit and geocatalog extractions. It was gradual instead: one merge in July, then a series of breaking releases, each absorbed per package. What the split was meant to unblock did happen. Aggregations, adapters (CogField, DaskField), samplers (AlongTrack) and the run machinery all shipped on the patcher’s own release cycle.

Trade-offs

The scoping report’s list, with how each turned out.

What got better:

  1. The patcher gets its own billing: in part. The repo and the import name are geopatcher, but the distribution is geotoolz-patcher, and the standalone repo that a search would find is archived (Section ??).

  2. The Field / Domain Protocols get visibility: yes. They are the root’s first exports, and geocatalog’s bridge depends on them.

  3. The aggregations get their own roadmap: yes. The family grew from about fifteen to 21 classes.

  4. The async story is clean: yes. AsyncSpatialPatcher, AsyncRasterField, prefetch and backpressure are first-class.

What got worse, and the mitigations:

  1. The test surface is big: 60 modules, in one monorepo CI.

  2. More extras to coordinate: each adapter stays behind its own extra, as planned.

  3. More documentation: the docs are tutorial-grade (concepts, a patching guide, recipes, ADRs, a notebook), but they live in the archived repo, with pre-0.9.0 names.

  4. People forget [pipekit]: the import error says what to install, though its PyPI note is out of date.

Open questions

  1. Where do the docs live now? The README, concepts, recipes, ADRs and figures are in an archived repo, and they use names removed in 0.9.0. Either they move into the geotoolz monorepo, or the archived repo is unarchived as the docs site. This mirrors geocatalog’s question 1 (Report 6).

  2. geopatcher or geotoolz-patcher on PyPI? Resolved: geotoolz-patcher (Report 16 §F).

  3. Should the core be georeader-free? The scoped core was numpy plus scipy, so that xarray, vector and point users would not pull in the raster stack. Moving the raster domain and fields behind a [raster] extra would restore that, at the cost of one more extra for the common case. The lazy half is Report 16 D3; the extra stays open.

  4. Should the axis classes be reachable from the root again? With the prefixes gone, Rectangular, Mean and RegularStride exist on both the spatial and the temporal side, so the root cannot re-export them flatly. Module-qualified imports (spatial.geometry.Rectangular) are the price of that.

  5. Should drop stay the default boundary? It silently leaves NaN strips when the stride does not divide the field (960 cells in the example above). "pad" or "reflect", or at least a warning on uncovered cells, would make the default safe.

  6. Should the pipekit bridge stop using private API? Resolved: nested_config becomes public (Report 16 §A).

  7. Should GridSampler and MergePatches be serialisable? They are flagged forbid_in_yaml, so a sliding-window pipeline cannot be saved and reloaded, which matters for the deployment use cases.

  8. What do geotoolz and xrtoolz actually need from the patcher? The scoped GeoTensor-aware operators (ReadFromCatalogAsPatches, GeoTensorChipFilter) were never built. For xrtoolz, resolved: it uses geopatcher, not xrpatcher (Report 16 D4).

Upcoming work

Table 11 lists the follow-ups.

Table 11:Follow-ups from checking the scoping report against the repo and the code.

Follow-upWhereWhy
Update the README and docs to the 0.9.0 names and MergePatchesgeopatcher docsgp.SpatialRectangular, gp.SpatialOverlapAdd and Stitch no longer exist
Mark "reflect" as shipped in the concepts pagegeopatcher docsit still says “planned follow-up”
Fix “base — RasterField only” in the READMEgeopatcher READMEthe base includes georeader, rasterio, pyproj and scipy
Drop the “pipekit isn’t on PyPI” noteREADME; integrations/pipekit.py import errorthe extra is pipekit>=0.0.2
Make nested_config public, and use it from geopatcher and geotoolzpipekit, geopatcher, geotoolzprivate API across packages (Report 3)
Warn when boundary="drop" leaves cells uncoveredspatial.geometry.Rectangular or mergesilent NaN strips
Return the field’s dtype from merge, or document float64SpatialPatcher.mergea float32 field merges to float64
Index geopatcher’s namespaces in the capability indexgeostack scripts/capabilities.pyonly 11 root names are listed today

References

Sources

Citations to be added: overlap-add reconstruction, and Hann and Tukey windows, as entries in references.bib.