Spaces:
Running on Zero
Running on Zero
File size: 6,349 Bytes
4bb7968 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | """
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() |