#!/usr/bin/env python3 """Acquire real native-resolution annual European TerraClimate grids, 1950–2025. Run: uv run --with-requirements requirements-data.txt python scripts/build_data.py Downloads use at most four concurrent HTTP requests; NetCDF decoding is sequential. Every annual cell requires all 12 months of both variables. Fixed breakpoints pool encoded RAW land cells across all selected years, equally weighted (not area- weighted). Annual valid masks may vary; years therefore contribute their actual valid-cell counts. Histogram order statistics exactly implement NumPy's linear percentiles without retaining the pooled sample array. """ from __future__ import annotations import argparse from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone import hashlib import gzip import sys import time import json from pathlib import Path from urllib.parse import urlencode import zipfile import numpy as np import requests from scipy.ndimage import gaussian_filter import shapefile import shapely from shapely.geometry import box, mapping, shape from shapely.geometry.polygon import orient import xarray as xr FIRST_YEAR = 1950 LAST_YEAR = 2025 GENERATOR_VERSION = 2 BBOX = (-25.0, 34.0, 60.0, 72.0) TEMPERATURE_PERCENTILES = [0, 20, 45, 70, 100] PRECIPITATION_PERCENTILES = [0, 60, 78, 90, 100] EQUAL_PERCENTILES = [0, 25, 50, 75, 100] NODATA = -32768 BASE = "https://thredds.northwestknowledge.net/thredds/ncss/" NATURAL_EARTH_URL = "https://naturalearth.s3.amazonaws.com/10m_cultural/ne_10m_admin_0_countries.zip" def climate_url(variable: str, year: int) -> str: west, south, east, north = BBOX # NCSS NetCDF4 export fails HTTP 500 for packed tmax; ppt supports it. accept = "netcdf" if variable == "tmax" else "netcdf4" query = { "var": variable, "south": int(south), "north": int(north), "west": int(west), "east": int(east), "disableProjSubset": "on", "addLatLon": "true", "horizStride": 1, "accept": accept, "time_start": f"{year}-01-01T00:00:00Z", "time_end": f"{year}-12-31T23:59:59Z", "timeStride": 1, } return BASE + f"grid/TERRACLIMATE_ALL/data/TerraClimate_{variable}_{year}.nc?" + urlencode(query) def acquire(url: str, target: Path, legacy_record: dict | None = None) -> Path: receipt = target.with_suffix(target.suffix + ".source.json") if target.exists(): record = json.loads(receipt.read_text()) if receipt.exists() else legacy_record if record and record["url"] == url and record["sha256"] == digest(target): if not receipt.exists(): write_json(receipt, source_record(url, target)) return target temporary = target.with_suffix(target.suffix + ".part") for attempt in range(3): try: print(f"Downloading {url}", flush=True) with requests.get(url, stream=True, timeout=(30, 900)) as response: response.raise_for_status() with temporary.open("wb") as output: for chunk in response.iter_content(1024 * 1024): output.write(chunk) # Reject error documents even when the service responds HTTP 200. with temporary.open("rb") as source: magic = source.read(8) if not (magic.startswith(b"CDF") or magic == b"\x89HDF\r\n\x1a\n" or url == NATURAL_EARTH_URL and magic.startswith(b"PK")): raise ValueError(f"Not a NetCDF/ZIP response from {url}: {magic!r}") temporary.replace(target) write_json(receipt, source_record(url, target)) return target except (requests.RequestException, ValueError): if attempt == 2: raise time.sleep(5 * (attempt + 1)) def digest(path: Path) -> str: with path.open("rb") as source: return hashlib.file_digest(source, "sha256").hexdigest() def source_record(url: str, path: Path) -> dict: return { "url": url, "sha256": digest(path), "bytes": path.stat().st_size, "retrievedAt": datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat(), } def json_value(value): if isinstance(value, np.ndarray): return value.tolist() if isinstance(value, np.generic): return value.item() return value def annual_field(path: Path, variable: str, year: int): # xarray decodes _FillValue/missing_value BEFORE CF scale_factor/add_offset. with xr.open_dataset(path, engine="netcdf4", mask_and_scale=True, decode_times=True) as dataset: field = dataset[variable].transpose("time", "lat", "lon") encoding = {key: json_value(field.encoding[key]) for key in ("scale_factor", "add_offset", "_FillValue", "missing_value") if key in field.encoding} dates = np.datetime_as_string(dataset.time.values, unit="D").tolist() expected = [f"{year}-{month:02d}-01" for month in range(1, 13)] if dates != expected: raise ValueError(f"{variable}: expected all 12 months exactly, got {dates}") west, south, east, north = BBOX field = field.where( (field.lon >= west) & (field.lon <= east) & (field.lat >= south) & (field.lat <= north), drop=True, ).sortby("lon").sortby("lat", ascending=False) values = np.asarray(field.values, dtype=np.float64) complete = np.isfinite(values).all(axis=0) annual = values.mean(axis=0) if variable == "tmax" else values.sum(axis=0) annual[~complete] = np.nan global_attributes = {key: json_value(value) for key, value in dataset.attrs.items()} details = { "dates": dates, "months": len(dates), "cfPacking": encoding, "units": field.attrs.get("units"), "description": field.attrs.get("description"), "globalAttributes": global_attributes, "completeYearCellsBeforeEuropeMask": int(complete.sum()), "aggregation": "arithmetic mean of 12 monthly maximum-temperature fields (not mean temperature; no day weighting)" if variable == "tmax" else "sum of 12 monthly accumulated precipitation fields", } print(f"{variable}: {dates}; packing={encoding}; shape={annual.shape}", flush=True) return annual, field.lon.values.copy(), field.lat.values.copy(), details def european_countries(path: Path): clipped = [] features = [] region = box(*BBOX) version = None with zipfile.ZipFile(path) as archive: for member in archive.namelist(): if member.upper().endswith(".VERSION.TXT"): version = archive.read(member).decode().strip() with archive.open("ne_10m_admin_0_countries.shp") as shp, \ archive.open("ne_10m_admin_0_countries.shx") as shx, \ archive.open("ne_10m_admin_0_countries.dbf") as dbf: reader = shapefile.Reader(shp=shp, shx=shx, dbf=dbf, encoding="utf-8") for record in reader.iterShapeRecords(): properties = record.record.as_dict() if properties["CONTINENT"] != "Europe": continue geometry = shape(record.shape.__geo_interface__).intersection(region) if geometry.is_empty: continue if not geometry.is_valid: geometry = shapely.make_valid(geometry) polygons = [geometry] if geometry.geom_type == "Polygon" else [ part for part in geometry.geoms if part.geom_type == "Polygon" ] if not polygons: continue geometry = shapely.union_all(polygons) clipped.append(geometry) # Display borders only are simplified; the raster mask uses unsimplified geometry. display = geometry.simplify(0.01, preserve_topology=True) display = orient(display, sign=1.0) if display.geom_type == "Polygon" else shapely.MultiPolygon( [orient(part, sign=1.0) for part in display.geoms] ) features.append({ "type": "Feature", "properties": {"name": properties["NAME_EN"], "continent": "Europe", "isoA3": properties["ADM0_A3"]}, "geometry": mapping(display), }) features.sort(key=lambda feature: feature["properties"]["name"]) return shapely.union_all(clipped), {"type": "FeatureCollection", "features": features}, version def encode_fields(temperature, precipitation, valid): encoded = np.full((*valid.shape, 2), NODATA, dtype=" np.iinfo(np.int16).max)): raise ValueError("Annual values exceed the signed-int16 contract") encoded[..., index][valid] = rounded.astype("