Module 1: The Cloud-Native Geospatial Stack
Phase: I, Foundations of the data, physics, and radar
Level: Intermediate
Estimated time: ~12 hours (about 4 h concepts, 6 h lab, 2 h write-up)
Prerequisites: Phase 0 complete (toolkit, EO mental model, geospatial fundamentals, the eo-portfolio repo).
Portfolio thread: starts Portfolio Project A. You build cube.py, a STAC-to-xarray cube fetcher, and produce a cloud-masked composite over your study-area fire AOI.
Why this module
Every remote-sensing and geospatial-ML job description lists the same stack: rasterio, xarray, GeoPandas, STAC, dask. The mindset shift this module delivers is to stop thinking of imagery as files you download and start thinking of it as lazily evaluated, labeled arrays with a coordinate reference system, fetched on demand from cloud catalogs. That shift is what separates someone who downloads a scene and opens it in QGIS from someone who can turn a search over a petabyte archive into an analysis-ready cube in a few lines. It is also the backbone every later module stands on, so time spent here pays off all course long.
Learning outcomes
After this module you can:
- Explain the STAC model (catalog, collection, item, asset) and query a STAC API by space, time, and properties.
- Explain cloud-optimized GeoTIFF (COG) and Zarr, and why cloud-native formats allow reading a window without downloading the whole file.
- Load a STAC search into a dask-backed xarray cube with
odc-stac, then clip, reproject, and composite it. - Use dask for lazy, chunked, parallel computation, and judge when it helps and when it hurts.
- Write a COG and reason about when a Zarr time cube is the better store.
- Work provider-agnostically by running the same workflow against more than one STAC API.
Concept lessons (about 4 hours)
Lesson 1: STAC, the index that makes archives usable
SpatioTemporal Asset Catalog (STAC) is the standard that makes petabyte archives searchable with a few HTTP calls. Its hierarchy:
- Catalog: the top-level entry point of a provider.
- Collection: a homogeneous dataset (for example
sentinel-2-l2a), with shared metadata and licensing. - Item: one scene, carrying a
datetime, a footprint (bbox and geometry), and properties such as cloud cover. - Asset: the actual files an item points to, usually one COG per band, plus thumbnails and metadata.
You query with pystac-client: give it a collection, a bounding box, a date range, and a property filter (for example cloud cover below twenty percent), and it returns the matching items. This replaces hunting through folder trees and per-provider download portals with one uniform, scriptable interface.
Lesson 2: Cloud-native formats, COG and Zarr
Two formats make cloud-native EO possible.
- COG (Cloud-Optimized GeoTIFF): a normal GeoTIFF laid out so it is tiled internally and carries overviews (downsampled pyramids), and hosted on a server that supports HTTP range requests. Together these let a client read just the tiles it needs for a small window, or a coarse overview for a thumbnail, without downloading the whole scene. This is why you can work on a 2 km AOI inside a 100 km scene in seconds.
- Zarr: a format for chunked, compressed n-dimensional arrays, designed for parallel reads and writes. It shines for time cubes and for outputs you will slice many ways, because any chunk can be fetched or written independently.
Rule of thumb: COG for single-scene imagery and for outputs other tools will consume; Zarr for dense multi-date time cubes you will analyze repeatedly.
Lesson 3: xarray and dask, labeled and lazy
- xarray gives arrays named dimensions, coordinates, and metadata. An image cube becomes a structure with dims
time,band,y,x, real-world coordinates on each axis, and a CRS attached (viarioxarray). You select by label (sel(time="2025-02")) instead of guessing axis order, which removes a whole class of bugs. - dask makes those arrays lazy and chunked. Operations build a computation graph rather than running immediately; nothing is read or computed until you call
.compute()(or write output). This lets you describe work on data larger than memory and run it in parallel across chunks.
When dask helps: data larger than RAM, many scenes, embarrassingly parallel work. When it hurts: small data where the scheduling overhead outweighs the work, or workflows with poor chunk alignment that force expensive reshuffles. The skill is choosing sensible chunk sizes (often aligned to the COG tiling and your AOI) and not reaching for dask when a plain numpy array fits in memory.
Lesson 4: From search to a clean cube
The pipeline you will build:
- Search a STAC API for scenes over the AOI and date range, filtered by cloud cover.
- Load the matching items into an xarray cube with
odc-stac, requesting only the bands, resolution, and bbox you need, chunked for dask. Usegroupby="solar_day"so scenes from the same pass are merged and overlaps de-duplicated. - Apply the cloud mask that ships with the product (for Sentinel-2 Level 2A, the Scene Classification Layer, SCL) so clouds and shadows are excluded.
- Composite over time (a per-pixel median is a robust default) to get one clean, gap-reduced image.
- Clip to the AOI, confirm the CRS, and write a COG.
Because the whole thing runs off STAC, the same code works against different providers by swapping the catalog URL, which is what "provider-agnostic" means in practice.
Guided lab (about 6 hours): build cube.py
You will build a reusable STAC-to-xarray cube fetcher and produce a cloud-masked composite over your fire AOI. Primary data source is the Copernicus Data Space STAC API, which is open and current.
Step 1: Search helper
"""cube.py: STAC to analysis-ready xarray cube."""
from __future__ import annotations
from pystac_client import Client
# Verify these before use: CDSE is the reliable open STAC API as of 2026.
STAC_CDSE = "https://stac.dataspace.copernicus.eu/v1"
def cloud_query(max_cloud: float) -> dict:
"""Pure helper: the STAC property filter for max cloud cover (unit-testable)."""
return {"eo:cloud_cover": {"lt": max_cloud}}
def search_scenes(bbox, datetime, max_cloud=20,
collection="sentinel-2-l2a", url=STAC_CDSE):
"""Return STAC items over bbox/date under the cloud threshold. (network)"""
client = Client.open(url)
search = client.search(collections=[collection], bbox=bbox,
datetime=datetime, query=cloud_query(max_cloud))
return list(search.items())
Step 2: Load a dask-backed cube with odc-stac
import odc.stac
def load_cube(items, bbox, bands=("B04", "B03", "B02", "B08", "SCL"),
resolution=10, chunks=None):
"""Load STAC items into a dask-backed xarray cube. (network)
Band/asset names differ by provider: CDSE uses B04/B03/B02/B08 and SCL.
Verify the asset keys of your collection before running.
"""
chunks = chunks or {"x": 1024, "y": 1024}
return odc.stac.load(
items, bands=bands, bbox=bbox, resolution=resolution,
groupby="solar_day", chunks=chunks,
)
Step 3: Cloud mask and composite (pure, testable)
The Sentinel-2 SCL band labels each pixel; classes for cloud, cloud shadow, and cirrus should be dropped. The median over time then yields a clean image.
# SCL classes to exclude: 3 shadow, 8 cloud med prob, 9 cloud high prob, 10 cirrus, 11 snow
_BAD_SCL = (3, 8, 9, 10, 11)
def mask_and_composite(ds, scl_band="SCL", bad=_BAD_SCL):
"""Mask cloudy pixels via SCL, then median-composite over time.
Pure array logic (no network); unit-testable on a synthetic cube.
"""
import numpy as np
good = ~ds[scl_band].isin(bad)
optical = ds.drop_vars(scl_band).where(good)
return optical.median(dim="time", skipna=True)
Step 4: Write a COG
def write_cog(composite, path):
"""Write an xarray dataset/array to a Cloud-Optimized GeoTIFF."""
# composite must carry a CRS (rioxarray). Convert dataset to a band-stacked array as needed.
arr = composite.to_array(dim="band") if hasattr(composite, "data_vars") else composite
arr.rio.to_raster(path, driver="COG")
Step 5: Tests that need no network
import numpy as np, xarray as xr
from eo_portfolio.cube import cloud_query, mask_and_composite
def test_cloud_query():
assert cloud_query(20) == {"eo:cloud_cover": {"lt": 20}}
def test_mask_and_composite_drops_clouds():
# two timesteps: t0 clean value 10, t1 cloudy (SCL=9) value 999
t, y, x = 2, 2, 2
red = xr.DataArray(np.array([[[10,10],[10,10]], [[999,999],[999,999]]], dtype="float32"),
dims=("time","y","x"))
scl = xr.DataArray(np.array([[[4,4],[4,4]], [[9,9],[9,9]]]), dims=("time","y","x"))
ds = xr.Dataset({"B04": red, "SCL": scl})
out = mask_and_composite(ds)
# cloudy timestep masked out, so median equals the clean value
assert float(out["B04"].mean()) == 10.0
Run pytest -q until green.
Step 6: The notebook and write-up
In notebooks/03_cube.ipynb: search a one-month window over your fire AOI, load the cube, mask and composite, plot a true-color image, and write the COG. In the repo README, add a short note explaining which provider you chose and why, and your COG-versus-Zarr decision for this output.
Step 7: Commit
git add src/eo_portfolio/cube.py tests/test_cube.py notebooks/03_cube.ipynb
git commit -m "Module 1: STAC-to-xarray cube fetcher with cloud-masked composite"
git push
Checkpoint
Self-check (answers below).
- Name the four levels of the STAC hierarchy and what an item versus an asset is.
- How does a COG let you read a 512 by 512 window from a 1 GB scene without downloading the whole file?
- You call
odc.stac.load(...)and it returns instantly on a huge date range. Why, and when does the actual reading happen? - Why is a per-pixel median a robust way to composite a stack of partly cloudy scenes?
Interview-style questions (practice out loud).
- Take me from a STAC query to a cloud-masked analysis-ready cube in under thirty lines, for any provider.
- Given a COG on object storage, how do you read one window without downloading the file, and what makes that possible?
- When would you store a time series as Zarr instead of a folder of COGs?
- When does dask make an EO workflow slower rather than faster?
Answers. (1) Catalog, collection, item, asset; an item is one scene with datetime and footprint, an asset is an actual file (usually a COG band) the item points to.
(2) The COG is internally tiled with overviews and served over HTTP range requests, so the client fetches only the byte ranges for the tiles covering that window (or a coarse overview), not the whole file.
(3) odc-stac builds a lazy dask-backed cube; nothing is read until you compute or write, so construction is instant and the reads happen on .compute() or on writing output.
(4) The median ignores the extreme values that clouds and shadows introduce (after masking, remaining outliers are damped), so it recovers a clean surface value per pixel across dates.
Deliverable
A tested cube.py (search, load, mask-and-composite, write-COG), a notebooks/03_cube.ipynb that produces a true-color, cloud-masked monthly composite over your fire AOI written as a COG, and a README note on provider choice and the COG-versus-Zarr decision. This is section 1 of Portfolio Project A.
What a hiring manager sees
The competency bar for this field literally includes "go from STAC query to cloud-masked analysis-ready cube in under thirty lines, lazily, for any provider." A clean cube.py that does exactly that, provider-agnostic and tested, is the first strong signal that you work cloud-native rather than downloading everything to a laptop. It is also the reusable foundation every other project in your portfolio will import.
Currency note
Verified August 2026. The Copernicus Data Space STAC API (stac.dataspace.copernicus.eu, collection sentinel-2-l2a) is the reliable, open primary source; confirm the exact API root and collection name at study time. Microsoft's public Planetary Computer data and STAC APIs remain open and available (only the hosted Hub notebook service was retired, in June 2024), while Microsoft's newer enterprise product is the paid Planetary Computer Pro; if you use the public Planetary Computer STAC as a second provider, confirm the collection you need is still populated before relying on it. odc-stac and stackstac are both maintained; this module uses odc-stac for its groupby="solar_day" de-duplication and strong mosaic throughput, and stackstac is a fine alternative. Band and asset names differ by provider (CDSE uses B04, B03, B02, B08, SCL), so check the asset keys of your collection, and pin all versions in your Module 0.1 environment file.