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.

03 — Operators on Sentinel-2 (Lake Tahoe NDVI)

Composing a Sentinel-2 NDVI pipeline

This notebook is the operator-composition slice of the canonical Lake Tahoe scenario: cloud-free Sentinel-2 NDVI over Lake Tahoe, summer 2024. The end-to-end multi-repo flow (catalog discovery → patching → operators) lives in geocatalog/docs/notebooks/end_to_end_lake_tahoe.ipynb. Here we focus on the geotoolz part: composing named operators from gz.radiometry, gz.indices, gz.cloud, and gz.mask into linear and DAG-shaped pipelines.

Scenario.

AOI bbox (EPSG:4326)(-120.10, 38.92, -119.93, 39.27) (Lake Tahoe main body)
Date range2024-06-01 to 2024-07-15 (peak-green Sierra window)
STACMicrosoft Planetary Computer (planetarycomputer.microsoft.com/api/stac/v1)
Collectionsentinel-2-l2a
MGRS tile10SGJ (fully covers the AOI; the other candidate 11SKD only clips a sliver)
Cloud cover< 5 %

The named operators we use:

StepOperatorFrom
DN → reflectanceDNToReflectance(scale=1e-4)gz.radiometry
Percentile stretch for the RGB previewPercentileClip(p_min=2, p_max=98)gz.radiometry
NDVINDVI(red_idx=2, nir_idx=3)gz.indices
NDWI (water/vegetation complement)NDWI(green_idx=1, nir_idx=3)gz.indices
Cloud-class mask from SCLMaskFromSCL(classes=SCL_CLOUDS_AND_INVALID)gz.cloud
Drop masked pixelsApplyMask(mask=..., fill_value=np.nan)gz.mask

When you need to write a new operator that does not yet live in geotoolz, the Define an operator recipe walks through the contract.

1. Load one Sentinel-2 scene via Microsoft Planetary Computer

We use pystac-client to find one low-cloud scene in the AOI / date range, then rioxarray to load three bands: B04 (Red, 10 m), B08 (NIR, 10 m), and the SCL scene-classification band (20 m, resampled to the 10 m grid with nearest-neighbour). Red + NIR go into one carrier rn_gt (the reflectance pipeline input); SCL goes into a separate carrier scl_gt (the cloud-mask source).

found 17 candidate items on tile 10SGJ
using S2B_MSIL2A_20240614T183919_R070_T10SGJ_20240615T003207  cloud_cover=0.0%
bgrn_gt: shape=(4, 3935, 1599)  dtype=uint16
scl_gt:  shape=(1, 3935, 1599)  dtype=uint8

2. True-color reference

Before running the index, plot a percentile-stretched true-color composite so we know what the scene looks like. gz.radiometry.PercentileClip is the named op for the stretch; np.transpose reorders (C, H, W) → (H, W, C) for matplotlib.

<Figure size 700x800 with 1 Axes>

3. Compose NDVI with Sequential

The simplest possible shape — DN → reflectance, then NDVI. Two named ops, one |. BGRN stack → red is at idx 2, NIR at idx 3.

Sequential([DNToReflectance(scale=0.0001, offset=0.0, axis=0), NDVI(nir_idx=3, red_idx=2, axis=0, eps=1e-10)])
ndvi shape: (3935, 1599)
ndvi range: -0.9999999992647058 0.999999999057493

4. Visualise the raw NDVI

Standard NDVI ramp. We use RdYlGn clipped at vmin=-0.4, vmax=0.9 so water shows red, conifer forest shows green, and the dry granite / scrub middle ground reads as a transition rather than alarming orange.

<Figure size 700x800 with 2 Axes>

5. Cloud-aware variant

We extract a “drop these pixels” mask from SCL using gz.cloud.MaskFromSCL, then attach gz.mask.ApplyMask at the end of the same Sequential. The mask is True on cloud + invalid pixels; ApplyMask follows the library convention (“True = drop”) so the call is just ApplyMask(mask=cloud_mask, fill_value=np.nan).

cloud_mask shape: (3935, 1599) dtype: bool
fraction masked: 0.000
Sequential([DNToReflectance(scale=0.0001, offset=0.0, axis=0), NDVI(nir_idx=3, red_idx=2, axis=0, eps=1e-10), ApplyMask(mask={'type': 'ndarray', 'shape': [3935, 1599], 'dtype': 'bool'}, fill_value=nan, invert=False)])
clean ndvi range: -0.9999999992647058 0.999999999057493
<Figure size 1300x800 with 3 Axes>

6. Fan-out — multiple indices from one scaled scene

gz.Fanout is sugar over a single-input Graph: one input, many named outputs. Here we compute NDVI and NDWI (the water-detection complement; high over open water, low over vegetation) off the same reflectance carrier — water and vegetation light up in opposite panels.

product keys: ['ndvi', 'ndwi']
ndvi shape: (3935, 1599)
ndwi shape: (3935, 1599)
<Figure size 1300x800 with 4 Axes>

7. Observability — inspect, time, assert

Operator is general enough that side-effects round-trip the same as transforms. pipekit ships a small library of identity-with-side- effect ops you compose inline:

PrimitiveRoleRe-exported as
Tap(fn, name=...)callback hook (logging / breakpoints)gz.Tap
ShapeTrace(mode="diff_only")log shape/dtype on changegz.ShapeTrace
Snapshot() + snap.at(key)capture intermediates by keygz.Snapshot
Profile() + prof.wrap(op)per-step wall-clockfrom pipekit import Profile
AssertShape(expected)runtime contract; raises QCErrorfrom pipekit import AssertShape
AssertDType(expected)runtime dtype contractfrom pipekit import AssertDType
Quarantine(check, sentinel)non-raising QC (route bad inputs)from pipekit import Quarantine

None of these change the pipeline output — they just observe or enforce. Drop them in with |, take them out for production.

input dtype: uint16
[ndvi] shape=(4, 3935, 1599) dtype=float64
[ndvi] shape=(3935, 1599) dtype=float64

Snapshot keys: ['reflectance']
reflectance carrier: (4, 3935, 1599) float64

Profile report:
  DNToReflectance       calls=1  mean=  32.9 ms
  NDVI                  calls=1  mean=  17.2 ms

What you’d reach for next:

  • Quarantine — route bad-cloud-fraction scenes to a sentinel instead of aborting the batch.
  • Cache — memoize by config + input hash for expensive ops (Ledoit-Wolf covariance, learned models).
  • Try / Coalesce / Retry — production control flow for flaky external loaders (STAC, S3).

Each is itself an Operator so the whole stack composes uniformly.

8. When to use which composition primitive

The cells above used Sequential (linear) and Fanout (one input, many outputs). The choice tree for the rest:

You want…Reach forWhy
A linear chain of 2–6 opsSequential (or op_a | op_b)Smallest API surface; flattens nested pipes.
Named intermediates / branching / fan-inGraphSame scene, multiple sub-pipelines, fused at the end.
Runtime two-way fork (e.g. reproject if geographic)BranchPredicate evaluates the whole carrier.
Sensor / product dispatch ('S2' vs 'L8')SwitchFinite named cases; default is Identity.
One input → many named derived productsFanoutSugar over a single-input Graph.

Each of these is itself an Operator, so they nest freely: a Graph inside a Sequential, a Switch whose cases are Sequentials, a Fanout whose values are Graphs.

8. Where next