Spaces:
Running on Zero
Running on Zero
| """ | |
| Samples ESA WorldCover's Sentinel-2 NDVI yearly percentile composite | |
| (p10/p50/p90) at station points, via the public S3 COGs. | |
| Unlike fetch_worldcover_landcover.py, this does NOT hand-compute the | |
| tile ID and guess the S3 key pattern -- VITO provides an authoritative | |
| grid file for exactly this purpose (found directly, not guessed): | |
| https://esa-worldcover.s3.eu-central-1.amazonaws.com/esa_worldcover_grid_composites.fgb | |
| This is a FlatGeobuf containing one feature per 1x1 degree composite | |
| tile, with (per VITO's own documentation) the real S3 path/URL for | |
| each tile as an attribute -- reading it means never guessing a file | |
| naming convention, which is exactly the class of guess that failed for | |
| the IDPR WMS layer name earlier in this session. | |
| The composites use a 1x1 degree grid, NOT the 3x3 degree grid the | |
| landcover classification map uses -- so unlike fetch_worldcover_ | |
| landcover.py's single hardcoded tile, station coordinates spanning | |
| more than 1 degree in either direction genuinely need more than one | |
| tile here. This script looks up each station's tile individually | |
| rather than assuming one tile covers everyone. | |
| NEEDS: geopandas (to read the grid FlatGeobuf) and rasterio (to sample | |
| the COGs). CAVEAT: the grid file's actual column names/schema are not | |
| independently verified here (no network access to inspect it directly) | |
| -- the script prints all columns and searches for a plausible URL/path | |
| column rather than assuming an exact name, and reports clearly if it | |
| can't find one. | |
| Usage: | |
| python -m scripts.fetch_worldcover_ndvi --check | |
| python -m scripts.fetch_worldcover_ndvi --stations datasets/station_elevations.csv | |
| """ | |
| import argparse | |
| from pathlib import Path | |
| import pandas as pd | |
| GRID_URL = "https://esa-worldcover.s3.eu-central-1.amazonaws.com/esa_worldcover_grid_composites.fgb" | |
| # The grid file's tile URLs use the s3:// scheme (confirmed against real | |
| # output: s3://esa-worldcover-s2/ndvi/2020/N48/...) -- GDAL's S3 driver | |
| # tries to SIGN requests with real AWS credentials by default even | |
| # though this bucket is fully public, unlike the HTTPS URL | |
| # fetch_worldcover_landcover.py used (a plain HTTPS GET needs no | |
| # signing at all, which is why that one worked without this). This | |
| # environment variable is the standard fix for reading a public S3 | |
| # bucket without needing real credentials. | |
| import os | |
| os.environ.setdefault("AWS_NO_SIGN_REQUEST", "YES") | |
| def load_grid(): | |
| import geopandas as gpd | |
| print(f"Loading tile grid from {GRID_URL} ...") | |
| grid = gpd.read_file(GRID_URL) | |
| print(f" {len(grid)} tile(s), columns: {list(grid.columns)}") | |
| return grid | |
| def find_url_column(grid) -> str: | |
| """ | |
| The grid's real column name for the tile's S3 path isn't | |
| independently confirmed -- search for a plausible one rather than | |
| assume, and fail loudly (not silently) if nothing matches. | |
| """ | |
| candidates = [c for c in grid.columns if any( | |
| kw in c.lower() for kw in ("url", "href", "path", "s3", "ndvi", "product", "file") | |
| )] | |
| if not candidates: | |
| raise ValueError( | |
| f"No column looks like a tile URL/path among {list(grid.columns)}. " | |
| f"Inspect the grid file's real schema directly (e.g. print(grid.head()) " | |
| f"in a notebook) and adjust find_url_column accordingly." | |
| ) | |
| print(f" candidate URL/path column(s): {candidates} -- using {candidates[0]!r}") | |
| return candidates[0] | |
| def check_access() -> bool: | |
| try: | |
| import geopandas # noqa: F401 | |
| import rasterio # noqa: F401 | |
| except ImportError as e: | |
| print(f"Missing dependency: {e}. pip install geopandas rasterio --break-system-packages") | |
| return False | |
| try: | |
| grid = load_grid() | |
| url_col = find_url_column(grid) | |
| print(f" Sample values from {url_col!r}: {grid[url_col].head(3).tolist()}") | |
| return True | |
| except Exception as e: | |
| print(f" FAILED: {e}") | |
| return False | |
| def fetch_for_stations(stations_path: Path, output_path: Path) -> None: | |
| import geopandas as gpd | |
| import rasterio | |
| from shapely.geometry import Point | |
| 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" | |
| grid = load_grid() | |
| url_col = find_url_column(grid) | |
| station_points = gpd.GeoDataFrame( | |
| stations, | |
| geometry=[Point(lon, lat) for lon, lat in zip(stations[lon_col], stations[lat_col])], | |
| crs=grid.crs, | |
| ) | |
| joined = gpd.sjoin(station_points, grid[[url_col, "geometry"]], how="left", predicate="within") | |
| rows = [] | |
| for tile_url, group in joined.groupby(url_col): | |
| if pd.isna(tile_url): | |
| for _, row in group.iterrows(): | |
| rows.append({"station_code": row["station_code"], "ndvi_p10": None, | |
| "ndvi_p50": None, "ndvi_p90": None}) | |
| continue | |
| print(f" opening {tile_url} for {len(group)} station(s)...") | |
| with rasterio.Env(AWS_NO_SIGN_REQUEST="YES"), rasterio.open(tile_url) as src: | |
| coords = list(zip(group[lon_col], group[lat_col])) | |
| values = list(src.sample(coords)) | |
| for (_, row), val in zip(group.iterrows(), values): | |
| # NDVI percentile composite is documented as 3 bands: p90, p50, p10 | |
| rows.append({"station_code": row["station_code"], | |
| "ndvi_p90": float(val[0]) if len(val) > 0 else None, | |
| "ndvi_p50": float(val[1]) if len(val) > 1 else None, | |
| "ndvi_p10": float(val[2]) if len(val) > 2 else None}) | |
| out = pd.DataFrame(rows) | |
| out.to_csv(output_path, index=False) | |
| print(f"Saved {len(out)} stations' NDVI values to {output_path}") | |
| print(out.describe()) | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description="Sample ESA WorldCover NDVI 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_ndvi.csv")) | |
| args = parser.parse_args() | |
| if args.check: | |
| check_access() | |
| return | |
| fetch_for_stations(args.stations, args.output) | |
| if __name__ == "__main__": | |
| main() |