Source code: Build a STAC catalogue in Python with pystac, rio-stac and pystac-client
Runnable Python scripts, requirements and console output for the tutorial “Build a STAC catalogue in Python with pystac, rio-stac and pystac-client”.
Last updated
These are the exact scripts used to produce every figure and number in Build a STAC catalogue in Python with pystac, rio-stac and pystac-client. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- stac_catalogue.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython stac_catalogue.pystac_catalogue.py
"""Build, validate and query a static STAC catalogue in Python with pystac and rio-stac, then compare itwith the public Earth Search and Microsoft Planetary Computer STAC APIs through pystac-client.
Companion script for https://technicalgis.com/tutorials/stac-catalogue-pystac-python/(part 2 of the series "Cloud-native rasters in Python").
Input: the 25 Cloud Optimised GeoTIFF tiles of Eryri (Snowdonia) made by part 1, cog_mosaic.py.If ../cloud-optimised-geotiff-python/output/tiles/ is missing, this script runs part 1 first.
Data: Copernicus DEM GLO-30 (c) DLR e.V. 2010-2014 and (c) Airbus Defence and Space GmbH 2014-2018,provided under COPERNICUS by the European Union and ESA; all rights reserved. Sentinel-2 L2A from theEarth Search catalogue (Copernicus Sentinel data 2026, processed by ESA).
Run: python stac_catalogue.pyOutputs (in output/): stac/ (the catalogue), six figures and this console log."""from __future__ import annotations
import http.serverimport jsonimport loggingimport reimport shutilimport subprocessimport sysimport threadingimport timeimport warningsfrom datetime import datetime, timezonefrom functools import partialfrom pathlib import Path
import jsonschemaimport matplotlibimport numpy as npimport pandas as pdimport geopandas as gpdimport pystacimport rasterioimport requestsfrom matplotlib.colors import LightSource, ListedColormapfrom matplotlib.patches import FancyBboxPatch, Rectanglefrom pystac.extensions.projection import ProjectionExtensionfrom pystac_client import Clientfrom rasterio.merge import mergefrom rasterio.vrt import WarpedVRTfrom rasterio.enums import Resamplingfrom rasterio.transform import from_originfrom rio_stac import create_stac_itemfrom shapely.geometry import Point, box, shape
matplotlib.use("Agg")import matplotlib.pyplot as plt # noqa: E402
HERE = Path(__file__).resolve().parentOUT = HERE / "output"STAC_DIR = OUT / "stac"PART1 = HERE.parent / "cloud-optimised-geotiff-python"TILE_DIR = PART1 / "output" / "tiles"OUT.mkdir(exist_ok=True)
EARTH_SEARCH = "https://earth-search.aws.element84.com/v1"PLANETARY_COMPUTER = "https://planetarycomputer.microsoft.com/api/stac/v1"SNOWDON_LONLAT = (-4.0763, 53.0685) # Yr Wyddfa summitGDAL_ENV = dict(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR", CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif")
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9, "figure.facecolor": "white", "axes.facecolor": "white"})pd.set_option("display.width", 140)pd.set_option("display.max_columns", 20)LIGHT = LightSource(azdeg=315, altdeg=45)DEM_CMAP = ListedColormap(plt.cm.terrain(np.linspace(0.25, 1.0, 256)))SEA = ListedColormap(["#9ecae1"])ASPECT = 1 / np.cos(np.radians(53.07))
def section(title: str) -> None: print(f"\n=== {title} ===")
def kb(n: float) -> float: return n / 1e3
# GDAL logs every HTTP range request it makes; count them as in part 1.RANGES: list[tuple[int, int]] = []
class RangeLog(logging.Handler): def emit(self, record): for a, b in re.findall(r"Downloading (\d+)-(\d+)", record.getMessage()): RANGES.append((int(a), int(b)))
logging.getLogger("rasterio").addHandler(RangeLog())
# ---------------------------------------------------------------- Step 0: make sure part 1's tiles existsection("Step 0: find the COG tiles from part 1")if len(list(TILE_DIR.glob("SH*.tif"))) < 25: print("part 1 outputs not found: running cog_mosaic.py (a few minutes)") subprocess.run([sys.executable, "cog_mosaic.py"], cwd=PART1, check=True, stdout=subprocess.DEVNULL)tiles = sorted(TILE_DIR.glob("SH*.tif"))print(f"{len(tiles)} COG tiles in {TILE_DIR.relative_to(HERE.parent)}, {sum(t.stat().st_size for t in tiles) / 1e6:.2f} MB")
# ---------------------------------------------------------------- Step 1: build the cataloguesection("Step 1: build a Catalog -> Collection -> Items tree with pystac and rio-stac")if STAC_DIR.exists(): shutil.rmtree(STAC_DIR)ACQUIRED = (datetime(2011, 1, 1, tzinfo=timezone.utc), datetime(2015, 12, 31, 23, 59, 59, tzinfo=timezone.utc))PROCESSED = datetime(2026, 11, 3, tzinfo=timezone.utc)
def source_glo30(bounds) -> list[str]: """Earth Search IDs of the 1 x 1 degree GLO-30 tiles that a footprint touches.""" ids = [] for lat in range(int(np.floor(bounds[1])), int(np.floor(bounds[3])) + 1): for lon in range(int(np.floor(bounds[0])), int(np.floor(bounds[2] - 1e-9)) + 1): ids.append(f"Copernicus_DSM_COG_10_N{lat:02d}_00_W{-lon:03d}_00_DEM") return ids
t0 = time.perf_counter()items = []for path in tiles: name = path.stem item = create_stac_item( str(path), id=f"eryri-dem-25m-{name}", input_datetime=ACQUIRED[0], asset_name="dem", asset_roles=["data"], asset_media_type=pystac.MediaType.COG, with_proj=True, with_raster=True, geom_densify_pts=10, geom_precision=6, properties={"os:grid_square": name, "processing:software": {"rasterio": rasterio.__version__}}, ) # A DEM built from 2011-2015 acquisitions has a date range, not an instant. item.datetime = None item.common_metadata.start_datetime, item.common_metadata.end_datetime = ACQUIRED item.common_metadata.created = PROCESSED item.common_metadata.title = f"Eryri DEM 25 m, OS square {name}" # rio-stac 0.12 writes the projection extension v1.1 (proj:epsg); upgrade to v2.0 (proj:code), as Earth Search uses. item.properties["proj:code"] = f"EPSG:{item.properties.pop('proj:epsg')}" item.stac_extensions = [u.replace("projection/v1.1.0", "projection/v2.0.0") for u in item.stac_extensions] with rasterio.open(path) as src: full = src.read(1) small = src.read(1, out_shape=(100, 100), resampling=Resampling.average) # rio-stac 0.12 reports valid_percent as a fraction divided by the pixel count; store the true percentage. item.assets["dem"].extra_fields["raster:bands"][0]["statistics"]["valid_percent"] = \ round(100 * float(np.isfinite(full).mean()), 2) # Thumbnail asset: a 100 x 100 px hillshaded preview. thumb = OUT / "thumbs" / f"{name}.png" thumb.parent.mkdir(exist_ok=True) rgb = LIGHT.shade(np.where(small > 0, small, 0), cmap=DEM_CMAP, vmin=0, vmax=1100, blend_mode="soft", vert_exag=1.5, dx=100, dy=100) rgb[small <= 0, :3] = (0.62, 0.79, 0.88) plt.imsave(thumb, rgb) item.add_asset("thumbnail", pystac.Asset(href=str(thumb), media_type=pystac.MediaType.PNG, roles=["thumbnail"], title="Hillshaded preview, 100 x 100 px")) for src_id in source_glo30(item.bbox): item.add_link(pystac.Link("derived_from", f"{EARTH_SEARCH}/collections/cop-dem-glo-30/items/{src_id}", media_type=pystac.MediaType.GEOJSON, title=f"Copernicus GLO-30 {src_id[22:36]}")) items.append(item)build_s = time.perf_counter() - t0
collection = pystac.Collection( id="eryri-dem-25m", title="Eryri DEM, 25 m, British National Grid", description=("Copernicus DEM GLO-30 over a 50 x 50 km area of Eryri (Snowdonia), mosaicked, reprojected to " "EPSG:27700 at 25 m and cut into 25 Cloud Optimised GeoTIFFs on the Ordnance Survey 10 km grid."), extent=pystac.Extent( pystac.SpatialExtent([[min(i.bbox[0] for i in items), min(i.bbox[1] for i in items), max(i.bbox[2] for i in items), max(i.bbox[3] for i in items)]]), pystac.TemporalExtent([[ACQUIRED[0], ACQUIRED[1]]]), ), license="other", keywords=["DEM", "Copernicus", "Eryri", "Snowdonia", "COG"], providers=[ pystac.Provider("European Space Agency / Airbus", roles=[pystac.ProviderRole.LICENSOR, pystac.ProviderRole.PRODUCER], url="https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model"), pystac.Provider("TechnicalGIS", roles=[pystac.ProviderRole.PROCESSOR, pystac.ProviderRole.HOST], url="https://technicalgis.com/tutorials/cloud-optimised-geotiff-python/"), ],)collection.add_link(pystac.Link("license", "https://spacedata.copernicus.eu/documents/20123/121286/CSCDA_ESA_Mission-specific+Annex_31_Oct_22.pdf", media_type="application/pdf", title="Copernicus DEM licence"))collection.add_items(items)collection.summaries.add("os:grid_square", sorted(i.properties["os:grid_square"] for i in items))collection.summaries.add("proj:code", ["EPSG:27700"])collection.summaries.add("gsd", [25])
catalog = pystac.Catalog(id="technicalgis-eryri", title="TechnicalGIS Eryri example catalogue", description="A static STAC catalogue built for the TechnicalGIS cloud-native rasters series.")catalog.add_child(collection)catalog.normalize_hrefs(str(STAC_DIR))
# Copy each COG and thumbnail next to its item JSON so the whole tree can be moved or uploaded as one folder.for item in collection.get_items(): item_dir = Path(item.get_self_href()).parent item_dir.mkdir(parents=True, exist_ok=True) for key, asset in item.assets.items(): dst = item_dir / Path(asset.href).name shutil.copy2(asset.href, dst) asset.href = str(dst)catalog.make_all_asset_hrefs_relative()t0 = time.perf_counter()catalog.save(catalog_type=pystac.CatalogType.SELF_CONTAINED)save_s = time.perf_counter() - t0
json_files = sorted(STAC_DIR.rglob("*.json"))data_files = [p for p in STAC_DIR.rglob("*") if p.is_file() and p.suffix != ".json"]print(f"pystac {pystac.__version__}, STAC version {pystac.get_stac_version()}; built 25 items in {build_s:.2f} s, " f"saved in {save_s:.2f} s")print(f"{len(json_files)} JSON files ({sum(p.stat().st_size for p in json_files) / 1e3:.1f} kB) and " f"{len(data_files)} asset files ({sum(p.stat().st_size for p in data_files) / 1e6:.2f} MB)")for p in json_files[:3] + [json_files[-1]]: print(f" {p.relative_to(OUT).as_posix():48s} {p.stat().st_size:6,d} bytes")sh65 = json.loads((STAC_DIR / "eryri-dem-25m" / "eryri-dem-25m-SH65" / "eryri-dem-25m-SH65.json").read_text())print("\nSH65 item, abridged:")print(json.dumps({ "stac_extensions": sh65["stac_extensions"], "bbox": sh65["bbox"], "properties": {k: v for k, v in sh65["properties"].items() if k not in ("proj:geometry", "proj:projjson", "proj:wkt2")}, "assets.dem": {k: v for k, v in sh65["assets"]["dem"].items() if k not in ("raster:bands", "bands")}, "dem band statistics": (sh65["assets"]["dem"].get("raster:bands") or sh65["assets"]["dem"]["bands"])[0]["statistics"], "links (rel)": [l["rel"] for l in sh65["links"]],}, indent=None, default=str).replace(", \"", ",\n \"")[:2500])
# ---------------------------------------------------------------- Step 2: validatesection("Step 2: validate every object against the STAC JSON schemas")t0 = time.perf_counter()n_valid = catalog.validate_all()first_s = time.perf_counter() - t0t0 = time.perf_counter()catalog.validate_all()second_s = time.perf_counter() - t0print(f"validate_all: {n_valid} items valid (plus the catalogue and collection); " f"first pass {first_s:.2f} s (fetches the schemas), second pass {second_s:.2f} s (schemas cached)")
no_start = json.loads(json.dumps(sh65))no_start["properties"].pop("start_datetime") # datetime is null, so start and end become mandatorybad_shape = json.loads(json.dumps(sh65))bad_shape["properties"]["proj:shape"] = "400 x 400" # must be an array of integersfor label, doc in [("start_datetime removed", no_start), ("proj:shape as a string", bad_shape)]: try: pystac.validation.validate_dict(doc) print(f" {label}: passed") except pystac.STACValidationError as err: best = jsonschema.exceptions.best_match(err.source) print(f" {label}: STACValidationError") print(f" schema: {str(err).splitlines()[0].split('schema at ')[-1]}") print(f" reason: {best.message[:110]} (at {'/'.join(map(str, best.absolute_path)) or 'item root'})")
# ---------------------------------------------------------------- Step 3: read back and query locallysection("Step 3: read the catalogue back and query it with GeoPandas")t0 = time.perf_counter()cat = pystac.Catalog.from_file(str(STAC_DIR / "catalog.json"))local_items = list(cat.get_items(recursive=True))read_s = time.perf_counter() - t0rows = []for it in local_items: band = (it.assets["dem"].extra_fields.get("raster:bands") or it.assets["dem"].extra_fields["bands"])[0] rows.append({"id": it.id, "square": it.properties["os:grid_square"], "min_m": band["statistics"]["minimum"], "max_m": band["statistics"]["maximum"], "mean_m": band["statistics"]["mean"], "href": it.assets["dem"].get_absolute_href(), "thumb": it.assets["thumbnail"].get_absolute_href(), "geometry": shape(it.geometry)})gdf = gpd.GeoDataFrame(rows, crs="EPSG:4326")proj = ProjectionExtension.ext(local_items[0])print(f"read {len(local_items)} items from disk in {read_s:.3f} s; projection extension on {local_items[0].id}: " f"code {proj.code}, shape {proj.shape}, bbox {proj.bbox}")
snowdon = Point(*SNOWDON_LONLAT)q_point = gdf[gdf.intersects(snowdon)]q_bbox_geom = box(-3.95, 53.10, -3.75, 53.25) # Conwy valleyq_bbox = gdf[gdf.intersects(q_bbox_geom)]q_high = gdf[gdf.max_m > 800].sort_values("max_m", ascending=False)print(f"point query (Yr Wyddfa {SNOWDON_LONLAT}): {q_point.square.tolist()}")print(f"bbox query (-3.95, 53.10, -3.75, 53.25): {len(q_bbox)} items {q_bbox.square.tolist()}")print(f"property query (maximum elevation > 800 m): {len(q_high)} items")print(q_high[["square", "min_m", "mean_m", "max_m"]].round(1).to_string(index=False))
# ---------------------------------------------------------------- Step 4: serve it as static filessection("Step 4: serve the catalogue as static files and crawl it over HTTP")HITS: list[tuple[str, int]] = []
class CountingHandler(http.server.SimpleHTTPRequestHandler): """Static file server that honours single HTTP Range requests and records every GET and its bytes."""
def log_message(self, *args): pass
def send_head(self): path = self.translate_path(self.path.split("?")[0]) if not Path(path).is_file(): self.send_error(404) return None size = Path(path).stat().st_size rng = re.match(r"bytes=(\d+)-(\d*)", self.headers.get("Range", "")) f = open(path, "rb") start, end = 0, size - 1 if rng: start = int(rng.group(1)) end = min(int(rng.group(2)) if rng.group(2) else size - 1, size - 1) self.send_response(206) self.send_header("Content-Range", f"bytes {start}-{end}/{size}") f.seek(start) else: self.send_response(200) self.send_header("Content-Type", self.guess_type(path)) self.send_header("Accept-Ranges", "bytes") self.send_header("Content-Length", str(end - start + 1)) self.end_headers() self._remaining = end - start + 1 if self.command == "GET": HITS.append((self.path, self._remaining)) return f
def copyfile(self, source, outputfile): outputfile.write(source.read(self._remaining))
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), partial(CountingHandler, directory=str(STAC_DIR)))threading.Thread(target=server.serve_forever, daemon=True).start()base = f"http://127.0.0.1:{server.server_address[1]}"
t0 = time.perf_counter()remote_cat = pystac.Catalog.from_file(f"{base}/catalog.json")crawled = list(remote_cat.get_items(recursive=True))crawl_s = time.perf_counter() - t0crawl_requests, crawl_bytes = len(HITS), sum(s for _, s in HITS)hits = [h for h, _ in HITS]print(f"crawl all items: {len(crawled)} items, {crawl_requests} GET requests, {crawl_bytes / 1e3:.1f} kB of JSON, " f"{crawl_s:.2f} s")print(f" first requests: {hits[:3]}")
HITS.clear()t0 = time.perf_counter()one = pystac.Item.from_file(f"{base}/eryri-dem-25m/eryri-dem-25m-SH65/eryri-dem-25m-SH65.json")one_s = time.perf_counter() - t0print(f"fetch one item you already know: {len(HITS)} request, {sum(s for _, s in HITS) / 1e3:.1f} kB, {one_s * 1000:.0f} ms")
try: with warnings.catch_warnings(): warnings.simplefilter("ignore") # NoConformsTo: a static catalogue advertises no API conformance static_client = Client.open(f"{base}/catalog.json") static_client.search(bbox=[-4.1, 53.0, -4.0, 53.1]).item_collection() print("pystac-client search on the static catalogue: worked")except Exception as err: # noqa: BLE001 print(f"pystac-client search on the static catalogue: {type(err).__name__}: {str(err).splitlines()[0][:100]}")
HITS.clear()with rasterio.Env(CPL_DEBUG=True, **GDAL_ENV): logging.getLogger("rasterio").setLevel(logging.DEBUG) RANGES.clear() t0 = time.perf_counter() with rasterio.open(one.assets["dem"].get_absolute_href()) as src: win = src.read(1, window=rasterio.windows.from_bounds(260_000, 353_000, 262_000, 355_000, src.transform)) cog_s = time.perf_counter() - t0 logging.getLogger("rasterio").setLevel(logging.WARNING)print(f"read a 2 x 2 km window of SH65 through its asset href: {len(RANGES)} range requests, " f"{sum(b - a + 1 for a, b in RANGES) / 1e3:.1f} kB of a " f"{(STAC_DIR / 'eryri-dem-25m' / 'eryri-dem-25m-SH65' / 'SH65.tif').stat().st_size / 1e3:.1f} kB file, " f"max {win.max():.1f} m, {cog_s * 1000:.0f} ms")server.shutdown()
# ---------------------------------------------------------------- Step 5: the same questions to public STAC APIssection("Step 5: search public STAC APIs with pystac-client")API_CALLS: list[tuple[str, float, int]] = []
def count_requests(client: Client): session = client._stac_io.session
def hook(resp, *args, **kwargs): API_CALLS.append((resp.request.method, resp.elapsed.total_seconds(), len(resp.content))) session.hooks["response"].append(hook) return client
aoi_bbox = [round(float(v), 4) for v in gdf.total_bounds]searches = []
API_CALLS.clear()t0 = time.perf_counter()es = count_requests(Client.open(EARTH_SEARCH))open_s = time.perf_counter() - t0print(f"Earth Search: opened landing page in {open_s:.2f} s; {len(list(es.get_collections()))} collections")
API_CALLS.clear()t0 = time.perf_counter()dem_search = es.search(collections=["cop-dem-glo-30"], bbox=aoi_bbox)es_dem = list(dem_search.items())s = time.perf_counter() - t0searches.append(("Earth Search\ncop-dem-glo-30", len(es_dem), len(API_CALLS), s, sum(c[2] for c in API_CALLS)))print(f"cop-dem-glo-30 in bbox {aoi_bbox}: {len(es_dem)} items, {len(API_CALLS)} POST request(s), {s:.2f} s")for it in es_dem: print(f" {it.id} datetime {it.datetime:%Y-%m-%d} {it.assets['data'].href}")our_sources = sorted({l.href.rsplit('/', 1)[-1] for i in local_items for l in i.get_links('derived_from')})print(f"our derived_from links point at the same {len(our_sources)} items: " f"{sorted(our_sources) == sorted(i.id for i in es_dem)}")
API_CALLS.clear()t0 = time.perf_counter()s2_search = es.search(collections=["sentinel-2-l2a"], intersects=box(*aoi_bbox).__geo_interface__, datetime="2026-05-01/2026-09-30", query={"eo:cloud_cover": {"lt": 10}}, sortby=[{"field": "properties.eo:cloud_cover", "direction": "asc"}], max_items=100)s2 = list(s2_search.items())s = time.perf_counter() - t0searches.append(("Earth Search\nsentinel-2-l2a", len(s2), len(API_CALLS), s, sum(c[2] for c in API_CALLS)))s2_df = pd.DataFrame([{"id": i.id, "date": i.datetime.date(), "mgrs": i.properties.get("grid:code", "")[5:], "cloud_%": i.properties["eo:cloud_cover"], "geometry": shape(i.geometry)} for i in s2])print(f"sentinel-2-l2a, May-Sep 2026, cloud < 10%: {len(s2)} items, {len(API_CALLS)} request(s), {s:.2f} s")print(s2_df.drop(columns="geometry").sort_values(["date", "mgrs"]).to_string(index=False))
pc_items = []try: API_CALLS.clear() t0 = time.perf_counter() pc = count_requests(Client.open(PLANETARY_COMPUTER)) pc_items = list(pc.search(collections=["cop-dem-glo-30"], bbox=aoi_bbox).items()) s = time.perf_counter() - t0 searches.append(("Planetary Computer\ncop-dem-glo-30", len(pc_items), len(API_CALLS), s, sum(c[2] for c in API_CALLS))) print(f"Planetary Computer cop-dem-glo-30: {len(pc_items)} items, {len(API_CALLS)} requests " f"(including the landing page), {s:.2f} s") print(f" same item IDs as Earth Search: {sorted(i.id for i in pc_items) == sorted(i.id for i in es_dem)}") print(f" example href: {pc_items[0].assets['data'].href.split('?')[0]}")except Exception as err: # noqa: BLE001 print(f"Planetary Computer search failed: {type(err).__name__}: {err}")
# ---------------------------------------------------------------- Step 6: read data through the search resultssection("Step 6: read pixels through asset hrefs from both catalogues")t0 = time.perf_counter()mosaic, mosaic_tf = merge(list(q_high.href), nodata=np.nan) # NaN where no item covers the bounding boxmosaic = mosaic[0]print(f"merged the {len(q_high)} items with maximum > 800 m from local hrefs: {mosaic.shape[1]} x {mosaic.shape[0]} px " f"in {time.perf_counter() - t0:.2f} s; max {np.nanmax(mosaic):.1f} m; {np.isnan(mosaic).mean() * 100:.0f}% of the box has no item")
best_date = s2_df.groupby("date").agg(n=("mgrs", "nunique"), cloud=("cloud_%", "mean")).query("n >= 2") \ .sort_values("cloud").index[0]# The same date can hold more than one processing of a tile (IDs ending _0 and _1); keep the least cloudy per tile.best_df = s2_df[s2_df.date == best_date].sort_values("cloud_%").drop_duplicates("mgrs")best = [i for i in s2 if i.id in set(best_df.id)]print(f"clearest date covering both MGRS tiles: {best_date}; " f"{(s2_df.date == best_date).sum()} items, using {', '.join(i.id for i in best)}")AOI_BNG = (250_000, 330_000, 300_000, 380_000)S2_RES = 100s2_shape = (int((AOI_BNG[3] - AOI_BNG[1]) / S2_RES), int((AOI_BNG[2] - AOI_BNG[0]) / S2_RES))s2_tf = from_origin(AOI_BNG[0], AOI_BNG[3], S2_RES, S2_RES)rgb = np.zeros((3, *s2_shape), dtype=np.uint8)RANGES.clear()logging.getLogger("rasterio").setLevel(logging.DEBUG)t0 = time.perf_counter()with rasterio.Env(CPL_DEBUG=True, **GDAL_ENV): for it in best: href = it.assets["visual"].href with rasterio.open(href) as src: ovr = src.overviews(1) size_mb = int(requests.head(href, timeout=60).headers["Content-Length"]) / 1e6 print(f" {it.id}: visual asset {src.width} x {src.height} px at 10 m, overviews {ovr}, {size_mb:.0f} MB") # Overview level 2 is the 1/8 overview (80 m), the closest one finer than our 100 m target. with rasterio.open(href, overview_level=2) as src, \ WarpedVRT(src, crs="EPSG:27700", transform=s2_tf, width=s2_shape[1], height=s2_shape[0], resampling=Resampling.average) as vrt: part = vrt.read() fill = (rgb.sum(axis=0) == 0) & (part.sum(axis=0) > 0) rgb[:, fill] = part[:, fill]s2_s = time.perf_counter() - t0logging.getLogger("rasterio").setLevel(logging.WARNING)s2_bytes = sum(b - a + 1 for a, b in RANGES)print(f"Sentinel-2 true colour over the 50 x 50 km area at {S2_RES} m: {len(RANGES)} range requests, " f"{s2_bytes / 1e6:.2f} MB, {s2_s:.1f} s")
# ---------------------------------------------------------------- Figuressection("figures")gdf["cx"] = gdf.geometry.representative_point().xgdf["cy"] = gdf.geometry.representative_point().y
# 01: the catalogue tree as it sits on diskfig, ax = plt.subplots(figsize=(11, 6.2))ax.set_xlim(0, 11)ax.set_ylim(0, 6.2)ax.axis("off")
def node(x, y, w, h, text, fc, fs=8.5): ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.04,rounding_size=0.08", fc=fc, ec="#333", lw=1)) ax.text(x + w / 2, y + h / 2, text, ha="center", va="center", fontsize=fs)
sizes = {p.parent.name.rsplit("-", 1)[-1]: p.stat().st_size for p in STAC_DIR.rglob("eryri-dem-25m-SH*.json")}cat_size = (STAC_DIR / "catalog.json").stat().st_sizecol_size = (STAC_DIR / "eryri-dem-25m" / "collection.json").stat().st_sizenode(0.2, 4.9, 2.6, 0.95, f"Catalog\ntechnicalgis-eryri\ncatalog.json ({cat_size:,} B)", "#fee08b")node(0.2, 3.25, 2.6, 1.15, f"Collection\neryri-dem-25m\ncollection.json ({col_size:,} B)\nextent, licence, providers", "#abdda4")ax.annotate("", (1.5, 4.4), (1.5, 4.9), arrowprops=dict(arrowstyle="->", lw=1.2))item_kb = np.mean(list(sizes.values())) / 1e3node(0.2, 0.5, 2.6, 2.2, f"Each item folder holds\nitem JSON (~{item_kb:.1f} kB)\n+ dem COG asset\n+ thumbnail PNG asset\n" "\nLinks: root, parent,\ncollection, derived_from\n(the source GLO-30 items)", "#e6f5d0", fs=8)ax.annotate("", (1.5, 2.7), (1.5, 3.25), arrowprops=dict(arrowstyle="->", lw=1.2))ax.annotate("", (3.3, 3.82), (2.8, 3.82), arrowprops=dict(arrowstyle="->", lw=1.2))for _, r in gdf.iterrows(): e, n = int(r.square[2]), int(r.square[3]) x, y = 3.4 + (e - 5) * 1.5, 0.4 + (n - 3) * 1.15 col = DEM_CMAP(min(r.max_m, 1100) / 1100) node(x, y, 1.35, 0.95, f"Item {r.square}\nmax {r.max_m:.0f} m\n{sizes[r.square] / 1e3:.1f} kB JSON", col, fs=7.5)ax.set_title(f"The static catalogue: 1 catalogue, 1 collection and 25 items in {len(json_files)} JSON files " f"(items laid out on the OS 10 km grid, coloured by maximum elevation)", fontsize=9.5)fig.savefig(OUT / "01-catalogue-tree.png")plt.close(fig)
# 02: item footprints by max elevation, and the thumbnails placed at their bounding boxesfig, axs = plt.subplots(1, 2, figsize=(12.5, 5.6))gdf.plot(ax=axs[0], column="max_m", cmap=DEM_CMAP, vmin=0, vmax=1100, edgecolor="#333", linewidth=0.8, legend=True, legend_kwds={"label": "Maximum elevation in item (m), from raster statistics", "shrink": 0.8})for _, r in gdf.iterrows(): axs[0].text(r.cx, r.cy, f"{r.square}\n{r.max_m:.0f} m", ha="center", va="center", fontsize=7)cb = collection.extent.spatial.bboxes[0]axs[0].add_patch(Rectangle((cb[0], cb[1]), cb[2] - cb[0], cb[3] - cb[1], fill=False, ec="#d53e4f", lw=1.6, ls="--"))axs[0].set_title("Item geometries (EPSG:4326) coloured by a property;\ndashed: the collection's spatial extent", fontsize=9)for _, r in gdf.iterrows(): b = r.geometry.bounds axs[1].imshow(plt.imread(r.thumb), extent=[b[0], b[2], b[1], b[3]]) axs[1].plot(*r.geometry.exterior.xy, color="white", lw=0.6)axs[1].set_title("The 25 thumbnail assets, each drawn at its item's bbox", fontsize=9)for a in axs: a.set_xlim(cb[0] - 0.02, cb[2] + 0.02) a.set_ylim(cb[1] - 0.02, cb[3] + 0.02) a.set_aspect(ASPECT) a.set_xlabel("Longitude (°)")axs[0].set_ylabel("Latitude (°)")fig.savefig(OUT / "02-item-footprints-thumbnails.png")plt.close(fig)
# 03: three local queriesfig, axs = plt.subplots(1, 3, figsize=(13.5, 4.9), gridspec_kw={"wspace": 0.12})qs = [(q_point, "Point: which item contains Yr Wyddfa?", lambda a: a.plot(*SNOWDON_LONLAT, "k^", ms=8)), (q_bbox, "Bbox: items touching the Conwy valley box", lambda a: a.plot(*q_bbox_geom.exterior.xy, color="black", lw=1.6, ls="--")), (q_high, "Property: maximum elevation > 800 m", lambda a: None)]for a, (sel, title, extra) in zip(axs, qs): gdf.plot(ax=a, color="#f0f0f0", edgecolor="#999", lw=0.6) gdf[gdf.id.isin(sel.id)].plot(ax=a, color="#3288bd", edgecolor="#08306b", lw=1, alpha=0.75) for _, r in gdf.iterrows(): a.text(r.cx, r.cy, r.square, ha="center", va="center", fontsize=6.5, color="white" if r.id in set(sel.id) else "#555") extra(a) a.set_title(f"{title}\n{len(sel)} of 25 items: {', '.join(sel.square) if len(sel) <= 6 else ''}".rstrip(": "), fontsize=9) a.set_aspect(ASPECT) a.set_xticks([]) a.set_yticks([]) a.set_xlabel("") a.set_ylabel("")fig.savefig(OUT / "03-local-queries.png")plt.close(fig)
# 04: requests, bytes and time: static crawl against API searcheslabels = ["Static catalogue\ncrawl (localhost)", "Static catalogue\none known item"] + [s[0] for s in searches]reqs = [crawl_requests, 1] + [s[2] for s in searches]secs = [crawl_s, one_s] + [s[3] for s in searches]nitems = [len(crawled), 1] + [s[1] for s in searches]fig, axs = plt.subplots(1, 2, figsize=(12, 4.6), sharey=True)colors = ["#fdae61", "#fee08b", "#3288bd", "#66c2a5", "#5e4fa2"][:len(labels)]y = np.arange(len(labels))[::-1]axs[0].barh(y, reqs, color=colors)for yi, v, n in zip(y, reqs, nitems): axs[0].text(v + 0.4, yi, f"{v} request{'s' if v != 1 else ''} for {n} item{'s' if n != 1 else ''}", va="center", fontsize=8)axs[0].set_yticks(y, labels, fontsize=8)axs[0].set_xlim(0, max(reqs) * 1.6)axs[0].set_xlabel("HTTP requests")axs[0].set_title("Requests needed to find the items", fontsize=9)axs[1].barh(y, secs, color=colors)for yi, v in zip(y, secs): axs[1].text(v + max(secs) * 0.02, yi, f"{v:.2f} s" if v >= 0.1 else f"{v * 1000:.0f} ms", va="center", fontsize=8)axs[1].set_xlim(0, max(secs) * 1.3)axs[1].set_xlabel("Wall-clock seconds")axs[1].set_title("Time taken (APIs over the internet from the UK)", fontsize=9)fig.savefig(OUT / "04-static-vs-api-requests.png")plt.close(fig)
# 05: our items against the Earth Search resultsfig, axs = plt.subplots(1, 2, figsize=(13, 5.6), gridspec_kw={"width_ratios": [1.15, 1]})es_gdf = gpd.GeoDataFrame([{"id": i.id, "geometry": shape(i.geometry)} for i in es_dem], crs="EPSG:4326")s2_gdf = gpd.GeoDataFrame(s2_df, crs="EPSG:4326")es_gdf.plot(ax=axs[0], facecolor="none", edgecolor="#9e0142", lw=2)for _, r in es_gdf.iterrows(): c = r.geometry.centroid axs[0].text(c.x, c.y + 0.32, r.id.replace("Copernicus_DSM_COG_10_", "").replace("_DEM", "").replace("_00", ""), ha="center", fontsize=8, color="#9e0142", fontweight="bold")for mg, colr in zip(sorted(s2_gdf.mgrs.unique()), ["#3288bd", "#f46d43", "#66c2a5", "#5e4fa2"]): g = s2_gdf[s2_gdf.mgrs == mg] g.plot(ax=axs[0], facecolor=colr, edgecolor=colr, alpha=0.06, lw=0.8) axs[0].plot([], [], color=colr, lw=4, alpha=0.6, label=f"Sentinel-2 L2A {mg}: {len(g)} scenes")gdf.plot(ax=axs[0], facecolor="#ffffbf", edgecolor="#333", lw=0.5, alpha=0.9)axs[0].plot([], [], color="#9e0142", lw=2, label="Copernicus GLO-30 items (Earth Search)")axs[0].fill_between([], [], color="#ffffbf", label="our 25 items")axs[0].legend(fontsize=7.5, loc="lower left", framealpha=0.95)axs[0].set_xlim(-5.4, -2.6)axs[0].set_ylim(51.85, 54.15)axs[0].set_aspect(ASPECT)axs[0].set_xlabel("Longitude (°)")axs[0].set_ylabel("Latitude (°)")axs[0].set_title("Search results from Earth Search around our catalogue", fontsize=9)for mg, mk, colr in zip(sorted(s2_df.mgrs.unique()), ["o", "s"], ["#3288bd", "#f46d43"]): g = s2_df[s2_df.mgrs == mg] axs[1].scatter(pd.to_datetime(g.date), g["cloud_%"], marker=mk, color=colr, s=40, label=f"tile {mg}", edgecolor="black", lw=0.5)axs[1].axvline(pd.to_datetime(best_date), color="#999", ls=":", lw=1)axs[1].text(pd.to_datetime(best_date), 9.6, f" {best_date}", fontsize=8, color="#555", va="top")axs[1].set_ylim(-0.4, 10)axs[1].set_ylabel("eo:cloud_cover (%)")axs[1].set_title("Sentinel-2 L2A, May to September 2026, eo:cloud_cover < 10", fontsize=9)axs[1].legend(fontsize=8, loc="center right")axs[1].grid(alpha=0.3)fig.autofmt_xdate()fig.savefig(OUT / "05-earth-search-results.png")plt.close(fig)
# 06: pixels read through the hrefs of the query resultsfig, axs = plt.subplots(1, 2, figsize=(12.5, 5.6))mb_ = rasterio.transform.array_bounds(*mosaic.shape, mosaic_tf)ext = [mb_[0], mb_[2], mb_[1], mb_[3]]axs[0].imshow(np.ma.masked_where(np.isnan(mosaic), LIGHT.hillshade(np.nan_to_num(mosaic), vert_exag=1.5, dx=25, dy=25)), cmap="gray", extent=ext)im = axs[0].imshow(np.ma.masked_invalid(np.where(mosaic > 0, mosaic, np.nan)), cmap=DEM_CMAP, vmin=0, vmax=1100, alpha=0.5, extent=ext)for _, r in gdf[gdf.id.isin(q_high.id)].to_crs("EPSG:27700").iterrows(): axs[0].plot(*r.geometry.exterior.xy, color="black", lw=0.6)fig.colorbar(im, ax=axs[0], shrink=0.75, label="Elevation (m)")axs[0].set_title(f"Local catalogue: the {len(q_high)} items with maximum > 800 m,\nmerged from their asset hrefs", fontsize=9)axs[1].imshow(np.transpose(rgb, (1, 2, 0)), extent=[AOI_BNG[0], AOI_BNG[2], AOI_BNG[1], AOI_BNG[3]])for _, r in gdf.to_crs("EPSG:27700").iterrows(): axs[1].plot(*r.geometry.exterior.xy, color="white", lw=0.5, alpha=0.8)axs[1].set_title(f"Earth Search: Sentinel-2 true colour, {best_date}, read at {S2_RES} m\n" f"from {len(best)} 'visual' COG assets ({s2_bytes / 1e6:.1f} MB in {len(RANGES)} range requests)", fontsize=9)for a in axs: a.set_xlim(AOI_BNG[0], AOI_BNG[2]) a.set_ylim(AOI_BNG[1], AOI_BNG[3]) a.xaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v / 1000:.0f}")) a.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v / 1000:.0f}")) a.set_xlabel("Easting (km, EPSG:27700)")axs[0].set_ylabel("Northing (km)")fig.savefig(OUT / "06-read-through-hrefs.png")plt.close(fig)
print("wrote", ", ".join(p.name for p in sorted(OUT.glob("*.png"))))