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.

Operator coupling: which dims an operator mixes

Design · a coupling class per operator, measured by perturbation and declared on the class

1Summary

Every geoops operator gets a coupling class: the set of dims along which some output depends on two or more input values that differ along that dim. An operator that couples nothing is pointwise, one that couples band is spectral, space spatial, time temporal, and the combinations follow. With the class come the effect on each dim (keep, select, reduce, regrid, …), the spatial reach in pixels, and whether a fitted state couples more than the apply step does.

A perturbation probe measures all of this today, without touching any operator: it ran on all 280 operator classes in geotoolz d2f53f4 (4 Nov 2026) and classified 244 of them. It reproduces two of the hand-kept lists in geoops’s contract test exactly, which is the first sign that those lists are coupling tags written by hand.

Each operator declares its class as a class attribute, and the contract test checks that the declared class contains what the probe measures (decided 4 Nov 2026, OC-Q1 to OC-Q3; built in jejjohnson/geotoolz#614). The class then feeds four things that today have no common vocabulary: the docs and the cheat sheet, the contract test’s special-case lists, the patcher (how much overlap a tile needs), and xrops.lift (which operators are safe frame by frame).

What does not change: the namespaces (indices, geom, …), the operators grid of needs × carrier family, and the operator contract.

2Where this sits

The operators page places an operation by what it needs to know (geometry, dim names, shapes) and by carrier family (geoarray, xarray). Coupling is a third, independent axis: where data flows inside the operation. NDVI and GaussianDenoise need the same thing (dim names), but one mixes bands and the other mixes neighbouring pixels.

Two words are already taken, so this page avoids them. Family means a carrier family on the operators page, and a namespace (indices, qa, …) in its “Families” tables and in geoops’s AGENTS.md. Signature means a KernelOp’s core-dims string, such as "(),()->()" or "(c)->(c)".

The KernelOp signature does not give the coupling class on its own. NDVI’s kernel is element-wise, "(),()->()", yet the operator reads two bands by label and returns one, so it couples band. The class is a property of the whole operator: label selection, kernel and rewrap together.

3Running example: screening a Sentinel-2 scene for a methane plume

A Sentinel-2 L2A tile covers 110 × 110 km: 5 490 × 5 490 pixels at 20 m in the SWIR, 12 bands stored as scaled integers, plus the scene classification layer (SCL). The screening chain masks clouds from the SCL, converts to reflectance, runs a matched filter for the CH₄ column enhancement, thresholds it, labels connected plume candidates and tabulates them. The question coupling answers is practical: which steps can run tile by tile on a patcher, with how much overlap, and which ones give a different answer per tile?

StepOperatorDeclared classProbe measuredPer tile
1S2SCLpointwise, band selectreads no values (the toy SCL band holds random 12-bit integers, almost none of them SCL classes)any tile
2DNToReflectancepointwisepointwiseany tile
3ColumnEnhancementspatio-spectro-temporal: band reduce; the background is fitted on every call, over every pixel and framespatio-spectro-temporal, globaldiffers per tile: the background is fitted on the tile
4Threshold (Otsu, the default)spatial, globalspatial, globaldiffers per tile
5LabelConnectedComponentsspatial, reach unboundedpointwise (a lower bound)needs tiles larger than the largest plume
6RegionPropsconversion: raster → tableconversionafter stitching

Steps 1 and 2 are safe anywhere. Steps 3 and 4 are global: the probe measured that a change in a far corner moves the centre output. Running them per tile is sometimes what you want (a local background), but it should be a choice the pipeline states, not an accident of tiling. Step 5 is the instructive one: the probe measured it as pointwise, because flipping one isolated pixel only changes that pixel’s label, yet joining two components can relabel pixels arbitrarily far away. The probe gives a lower bound; the declared class must be at least as wide.

4Definition

Write the input as xx with index j=(t,c,z,s)j = (t, c, z, s): time tt, band or variable cc, vertical level zz and pixel s=(y,x)s = (y, x), any of the first three possibly absent. An operator TT maps xx to an output with index oo. Output oo depends on input jj when changing xjx_j changes T(x)oT(x)_o; for a differentiable operator, when ∂T(x)o/∂xj≠0\partial T(x)_o / \partial x_j \neq 0 somewhere.

TT couples a dim dd when some output depends on two or more inputs that differ only along dd: a filter mixes neighbouring pixels, a band ratio mixes bands, a composite mixes dates. Re-indexing alone (selecting or reordering bands, a crop, a flip) moves values without mixing them, so it is an effect on dd, not a coupling.

The coupling set K(T)\mathcal{K}(T) is the set of coupled dims, with yy and xx counted together as space.

K(T)\mathcal{K}(T)ClassExamples
∅\emptysetpointwiseDNToReflectance, ApplyMask, SelectBands, StandardScaler (fitted over space and time)
bandspectralNDVI, BandMath, ContinuumRemoval, ApplySRF
spacespatialGaussianDenoise, Gradient2D, Reproject, PercentileClip
timetemporalMedianComposite, BlendMatched
band, timespectro-temporalMaxNDVIComposite, the Pixelwise* estimators (fitted over space)
level, spacevolumetricGradient3D, Laplacian3D
band, spacespatio-spectralSLIC, Felzenszwalb
space, timespatio-temporalHistogramStretch, ZeroOne (statistics over every pixel and frame)
band, space, timespatio-spectro-temporalDenoisePCA, ColumnEnhancement, PixelwiseIPCA

Four more facts complete the class.

5Measuring it

geoops.testing.measure_coupling runs an operator on a sample and perturbs it; tests/test_operator_coupling.py (and scripts/operator_coupling.py for a report) does so for every operator the contract test can build, on the first toy input it accepts.

  1. Local probe. Bump the centre pixel of the sample, one band at a time. The outputs that move give the band dependence at that pixel and the spatial reach.

  2. Non-local probe. Bump, darken and brighten a 3 × 3 block in a far corner. If the centre output moves, the operator reads beyond any local neighbourhood.

  3. Apply-only probe. Repeat the non-local probe through an operator already called once, so a state fitted on first call stays frozen. Global in the second probe but not here means “fit over space”.

  4. Time probe. Perturb frame 0 of a 2-frame stack and see whether frame 1’s output moves; for the N-ary reducers, perturb each input frame in turn.

The test reuses the contract test’s tables (CTOR_KWARGS, MULTI_INPUTS, GRAPH_PRIMARY, NARY_INPUTS) rather than a second set of fixtures.

The probe measures a lower bound on K\mathcal{K}, for two reasons. A rank or topology statistic can ignore one changed pixel (LabelConnectedComponents, the morphology masks). And a toy input can make an operator the identity: ReprojectLike with a like grid equal to the input grid.

6First pass: what the probe found

Read geotoolz main at d2f53f4 on 4 Nov 2026; 280 operator classes, 244 probed.

Measured classOperators
spatial51
pointwise50 (about ten are lower-bound artefacts, listed below)
reads no values40 (grid-only operators, and toy inputs too simple to move the output)
conversion26
spectral25
spatio-spectral13
spatio-temporal8
spatio-spectro-temporal7
temporal6
fan-out6
pointwise, time select4 (Mosaic, MinCloudComposite, BAPComposite, CombineMasks)
volumetric4
join, rearrange2 + 2 (StackBands, StackMatched; CHWtoHWC, HWCtoCHW)
not probed36 (11 readers, the SourceOperator and SinkOperator bases, 6 point and vector conversions, 3 optional ML masks, 14 needing inputs the toy set lacks)

The findings that matter:

The full per-operator table is the script’s output (--out DIR writes coupling.json and coupling.md). After the declarations landed, the derived lists contain the hand lists, the four pooling gaps are in the derived POOLED_STATISTICS, and with the estimators seeded the probe confirms 11 of 12 STACK_AS_FEATURES members (PixelwiseGMM’s toy labels do not move).

7The tag

A frozen record on each operator class, read by the docs generator, the contract test, the patcher and, later, xrops.lift.

# geoops/_src/coupling.py; public as geoops.carrier.Coupling
@dataclasses.dataclass(frozen=True, init=False)
class Coupling:
    # 𝒦_apply: the dims an output reads across, from {"band", "level", "space", "time"}
    couples: frozenset[str]
    # the effect on each dim the operator changes: "select", "reduce", "regrid", …
    effects: tuple[tuple[str, str], ...]
    # r: the spatial reach in pixels at the default settings; None for a global operator
    reach: int | None
    # 𝒦_fit ∖ 𝒦_apply: the further dims a fitted state is estimated over
    fit: frozenset[str]
    # "transform", "fan-out", "conversion", "source" or "sink"
    kind: str
    # False when the output depends only on the input's grid
    reads_values: bool


# declared on the class, beside `backends`
class NDVI(Operator):
    backends: ClassVar[tuple[str, ...]] = ("numpy", "jax", "dask")
    coupling: ClassVar[Coupling] = Coupling(couples={"band"}, effects={"band": "reduce"})


class StandardScaler(Operator):
    # x′ = (x − μ_c) / σ_c pointwise; μ_c, σ_c fitted over every pixel and frame
    coupling: ClassVar[Coupling] = Coupling(fit={"space", "time"})


class GaussianDenoise(Operator):
    coupling: ClassVar[Coupling] = Coupling(couples={"space"}, reach=4)

    def spatial_reach(self) -> int:
        # r = ⌈4σ⌉ pixels, scipy's default truncation
        return int(np.ceil(4 * self.sigma))

The class name (spectral, spatio-temporal, …) is derived from couples, never stored, so it cannot disagree with the record. A reach that depends on a constructor argument (σ, window, iterations) comes from spatial_reach(); geoops.carrier.reach_of(op) reads either.

The alternative was a YAML table in geoops keyed by class name. It needs no change to any operator, but nothing then stops a new operator from shipping without a class, and the contract test has to keep the table and the code in step.

8What reads the tag

ReaderUsesReplaces
cheat sheet, diagramscards and legends coloured by classnothing
docsdocs/operators/api/coupling.md, generated beside backends.mdnothing; today operators are grouped only by namespace
geoops contract testtime in couples ∪ fit selects the pooled operators; reads_values=False the grid-only onesPOOLED_STATISTICS, TIME_INVARIANT, STACK_AS_FEATURES
geopatcher (ApplyToChips)warns (PatchReachWarning) when neighbouring patches overlap by less than 2r2r, or for a global operatornothing; the overlap is implicit in a sampler’s size and step today
xrops.lift(op, dim="time")safe when time ∉ couples ∪ fit; otherwise refuse or fit once on the whole cubenothing; lifting assumes per-frame operators

9Decisions and open questions

IDQuestionRecommendedAlternative
OC-Q1The nameDecided 4 Nov 2026: coupling (and “coupling class”)“family” (taken twice, see above); “scope”; “footprint” (taken by geoarray.footprint)
OC-Q2Where the class livesDecided 4 Nov 2026: a coupling class attribute on each operator, a frozen Coupling record in geoops/_src/coupling.py, public in geoops.carriera YAML table in geoops keyed by class name
OC-Q3The dim vocabularyDecided 4 Nov 2026: geoarray’s dims, with y x as one spacey and x as separate dims
OC-Q4Measured against declaredmeasured ⊆ declared for every operator, and equality where the probe reads the maths exactly: indices, spectral, radiometry (built that way in jejjohnson/geotoolz#616; qa and normalize are not exact on toy inputs)equality everywhere, with an exceptions list
OC-Q5The axis of the N-ary reducerstime for compositing; a source axis when the inputs are different sensors (BlendMatched, MatchedField). Built with time for every N-ary reducer for nowtime for every stack
OC-Q6Where the probe livesgeoops.testing.measure_coupling / assert_coupling, so downstream packages can check their own operators; scripts/operator_coupling.py reports (built in jejjohnson/geotoolz#615)keep it a script

10Testing gates

GatePasses when
OC-G1 Hand liststhe derived lists contain the hand lists; the probe confirms TIME_INVARIANT 11/12 and STACK_AS_FEATURES 11/12, and finds no pooling operator outside POOLED_STATISTICS (holds)
OC-G2 Every operator declaresevery public operator class has a coupling; the measured K\mathcal{K} is a subset of the declared one on every probed operator (holds)
OC-G3 Exact where the probe is exactfor indices, spectral and radiometry, measured equals declared (holds)
OC-G4 Tiles with enough overlapa declared-local operator run per tile with overlap ≥ 2r2r and zero window weight on each tile’s outer rr pixels, stitched with OverlapAdd, equals the whole-scene result to 1e-12 in float64 (holds for a 3 × 3 mean in the patcher’s tests)
OC-G5 Liftingxrops.lift(op, dim="time") equals the per-frame loop bit for bit for every operator with time ∉ couples ∪ fit_couples, and refuses the rest

11Status

Built as one theme in geotoolz, jejjohnson/geotoolz#614, as stacked PRs:

  1. the probe script (jejjohnson/geotoolz#613);

  2. Coupling, measure_coupling, assert_coupling (#615);

  3. declarations on indices, spectral, radiometry, normalize, qa (#616);

  4. declarations on every other operator, required by the contract test (#617);

  5. the contract test’s time-stack lists derived from the declarations (#618);

  6. the generated docs/operators/api/coupling.md (#619);

  7. PatchReachWarning in ApplyToChips (#620).

Still open: OC-Q5 (a source axis) and OC-G5, which waits for xrops.lift.