""" Samples ESA WorldCover landcover classification and NDVI at station points, from the public AWS S3 Cloud-Optimized GeoTIFF tiles -- not the Terrascope WMS. WHY NOT WMS: a source dated July 2026 reports Terrascope's WMS actively resets connections from non-browser HTTP clients (TLS fingerprinting, confirmed across curl/wget/Node fetch with multiple User-Agents) -- this is not a coding problem to work around, it's the service declining non-browser clients. Separately, that WMS was scheduled for full phase-out in January 2026, already past. Using ESA's own recommended alternative instead: direct S3 access, "avoids unzipping steps" per ESA's own documentation, genuinely public, no auth/signing. TILE: ESA WorldCover ships as 3x3 degree COG tiles named by their southwest corner, e.g. "N48E000". All of this project's real station coordinates (lon 0.43-1.54E, lat 48.39-49.34N) fall inside exactly one tile: N48E000 -- computed directly (floor each coordinate to the nearest 3-degree grid line), not guessed. CAVEAT: the exact object key/path within the S3 bucket is reasoned from documented ESA WorldCover file-naming conventions (ESA_WorldCover_10m_2021_v200_{tile}_Map.tif for classification), NOT independently verified against a live S3 listing -- no network access in the environment this was written in to confirm it directly. Run --check first. NEEDS: rasterio (for reading COG tiles and sampling point values). Usage: python -m scripts.fetch_worldcover_landcover --check python -m scripts.fetch_worldcover_landcover --stations datasets/station_elevations.csv """ import argparse from pathlib import Path import pandas as pd BUCKET_BASE = "https://esa-worldcover.s3.eu-central-1.amazonaws.com" TILE = "N48E000" CLASSIFICATION_URL = f"{BUCKET_BASE}/v200/2021/map/ESA_WorldCover_10m_2021_v200_{TILE}_Map.tif" # 11-class legend, from ESA's own product documentation -- needed to make # the raw integer codes in the classification raster human-readable. LANDCOVER_CLASSES = { 10: "Tree cover", 20: "Shrubland", 30: "Grassland", 40: "Cropland", 50: "Built-up", 60: "Bare/sparse vegetation", 70: "Snow and ice", 80: "Permanent water bodies", 90: "Herbaceous wetland", 95: "Mangrove", 100: "Moss and lichen", } def compute_tile_id(lat: float, lon: float) -> str: """3-degree grid tile ID containing (lat, lon), named by SW corner.""" import math tile_lat = math.floor(lat / 3) * 3 tile_lon = math.floor(lon / 3) * 3 lat_str = f"N{tile_lat:02d}" if tile_lat >= 0 else f"S{-tile_lat:02d}" lon_str = f"E{tile_lon:03d}" if tile_lon >= 0 else f"W{-tile_lon:03d}" return f"{lat_str}{lon_str}" def check_access() -> bool: try: import rasterio except ImportError: print("rasterio is not installed -- this is required. " "pip install rasterio --break-system-packages") return False print(f"Checking {CLASSIFICATION_URL} ...") try: with rasterio.open(CLASSIFICATION_URL) as src: print(f" OK: opened tile, shape={src.shape}, crs={src.crs}, dtype={src.dtypes[0]}") # sample one known real point (a real station's coordinates) test_lat, test_lon = 49.03, 0.79 vals = list(src.sample([(test_lon, test_lat)])) print(f" Sample at ({test_lat}, {test_lon}): {vals[0][0]} " f"({LANDCOVER_CLASSES.get(int(vals[0][0]), 'unknown class')})") return True except Exception as e: print(f" FAILED: {e}") print(f" The object key may not match the real S3 layout. Check the ESA WorldCover " f"AWS registry page directly: https://registry.opendata.aws/esa-worldcover/") return False def fetch_for_stations(stations_path: Path, output_path: Path) -> None: import rasterio stations = pd.read_csv(stations_path) lat_col = "latitude" if "latitude" in stations.columns else "lat" lon_col = "longitude" if "longitude" in stations.columns else "lon" # Confirm every station actually falls in the hardcoded tile before # trusting a single-tile fetch -- if a future node set (e.g. the # full ~4,500-node reach graph, not just the 27 gauges) extends # beyond N48E000, this needs multiple tiles, not silently wrong data # from the one tile that happens to be loaded. tile_ids = stations.apply(lambda r: compute_tile_id(r[lat_col], r[lon_col]), axis=1) unexpected = tile_ids[tile_ids != TILE].unique() if len(unexpected) > 0: print(f"WARNING: some stations fall outside tile {TILE}: {list(unexpected)}. " f"This script only fetches {TILE} -- results for those stations will be wrong " f"or missing. Extend BUCKET fetching to cover {list(unexpected)} too.") with rasterio.open(CLASSIFICATION_URL) as src: coords = list(zip(stations[lon_col], stations[lat_col])) values = [v[0] for v in src.sample(coords)] out = stations[["station_code"]].copy() out["landcover_class_code"] = values out["landcover_class_name"] = [LANDCOVER_CLASSES.get(int(v), "unknown") for v in values] out.to_csv(output_path, index=False) print(f"Saved {len(out)} stations' landcover class to {output_path}") print(out["landcover_class_name"].value_counts()) def main() -> None: parser = argparse.ArgumentParser(description="Sample ESA WorldCover landcover at station points") parser.add_argument("--check", action="store_true") parser.add_argument("--stations", type=Path, default=Path("datasets/station_elevations.csv")) parser.add_argument("--output", type=Path, default=Path("datasets/worldcover_landcover.csv")) args = parser.parse_args() if args.check: check_access() return fetch_for_stations(args.stations, args.output) if __name__ == "__main__": main()