Spaces:
Running
Running
File size: 10,819 Bytes
2fe3948 7232d71 2fe3948 340b37f 2fe3948 54b22df 39279fa 4b7880a 39279fa 54b22df 2fe3948 7232d71 2fe3948 7232d71 2fe3948 7232d71 54b22df 2fe3948 7232d71 340b37f 7232d71 54b22df 7232d71 54b22df 2fe3948 54b22df 340b37f 54b22df 340b37f 54b22df 39279fa 54b22df 4b7880a 54b22df 39279fa 54b22df 2fe3948 | 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 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 | """HF-backed basic-data adapter for the AQUAH->CREST_demo integration.
Replaces AQUAH's live basic-data download + clip: windowed-reads the HydroSHEDS
DEM / flow-dir / flow-acc COGs hosted in `vincewin/CREST_data` and writes the
three EF5 inputs (dem_clip.tif, fdir_clip.tif, facc_clip.tif) for a basin bbox.
All three HydroSHEDS grids share one grid, so clipping the same bbox yields
pixel-aligned outputs (EF5 masks the catchment internally from the outlet cell).
On this network GDAL's curl needs GDAL_HTTP_UNSAFESSL=YES (corporate MITM);
that is LOCAL-ONLY and not set on HF cloud runners.
"""
from __future__ import annotations
import os
import time
from dataclasses import dataclass
import rasterio
from rasterio.windows import from_bounds
HF_REPO = "vincewin/CREST_data"
_RESOLVE = f"https://huggingface.co/datasets/{HF_REPO}/resolve/main"
# EF5 input filename <- COG path in CREST_data
BASIC_GRIDS = {
"dem_clip.tif": "basic/na_con_3s.tif",
"fdir_clip.tif": "basic/na_dir_3s.tif",
"facc_clip.tif": "basic/na_acc_3s.tif",
}
def _vsicurl(cog_path: str) -> str:
return f"/vsicurl/{_RESOLVE}/{cog_path}"
def _cog_src(cog: str, force_download: bool = False) -> str:
"""Local whole-file copy when cached (or force_download) else /vsicurl.
Windowed /vsicurl reads are anonymous range requests — under fleet-scale
concurrency HF rate-limits them (RasterioIOError storms across every
runner), so a local copy (authenticated hf_hub download, shared across
processes via HF_HOME) is preferred whenever it exists."""
try:
from huggingface_hub import hf_hub_download, try_to_load_from_cache
p = try_to_load_from_cache(HF_REPO, cog, repo_type="dataset")
if isinstance(p, str) and os.path.exists(p):
return p
if force_download:
return hf_hub_download(HF_REPO, cog, repo_type="dataset",
token=os.environ.get("HF_TOKEN"))
except Exception:
pass
return _vsicurl(cog)
def prefetch() -> None:
"""Best-effort download of the three CONUS COGs (~6.9 GB) into the HF
cache so every later clip reads locally. Bulk runners (the fleet) call
this once at startup; interactive use never needs it."""
from huggingface_hub import hf_hub_download
for cog in BASIC_GRIDS.values():
try:
hf_hub_download(HF_REPO, cog, repo_type="dataset",
token=os.environ.get("HF_TOKEN"))
except Exception:
pass
@dataclass
class ClipResult:
out_dir: str
files: dict[str, str]
width: int
height: int
bounds: tuple[float, float, float, float]
derived: bool = False # dir/acc regenerated from the DEM (pysheds)
# ESRI D8 codes EF5 expects with ESRIDDM=true (E,SE,S,SW,W,NW,N,NE)
_ESRI_D8 = (1, 2, 4, 8, 16, 32, 64, 128)
def _dir_acc_usable(fdir_path: str, facc_path: str, dem_path: str,
max_bad_frac: float = 0.05) -> bool:
"""Sanity-check the clipped flow-dir/acc: on cells where the DEM is valid,
the direction must be an ESRI D8 code and the accumulation non-negative."""
import numpy as np
with rasterio.open(dem_path) as d, rasterio.open(fdir_path) as f, \
rasterio.open(facc_path) as a:
dem, fdir, facc = d.read(1), f.read(1), a.read(1)
ok_dem = dem != -9999.0
if not ok_dem.any():
return False
good_dir = np.isin(fdir, _ESRI_D8)
good_acc = facc >= 0
bad = ok_dem & ~(good_dir & good_acc)
return (bad.sum() / ok_dem.sum()) <= max_bad_frac
def derive_dir_acc(dem_path: str, fdir_path: str, facc_path: str) -> None:
"""Fallback: derive D8 flow direction + accumulation from the clipped DEM
with pysheds (condition -> flowdir -> accumulation). pysheds' default
dirmap (N,NE,E,SE,S,SW,W,NW)=(64,128,1,2,4,8,16,32) IS the ESRI encoding."""
import numpy as np
from pysheds.grid import Grid
grid = Grid.from_raster(dem_path)
dem = grid.read_raster(dem_path)
dem_f = grid.fill_pits(dem)
dem_f = grid.fill_depressions(dem_f)
dem_f = grid.resolve_flats(dem_f)
fdir = grid.flowdir(dem_f)
facc = grid.accumulation(fdir)
with rasterio.open(dem_path) as ref:
profile = ref.profile.copy()
profile.update(dtype="float32", nodata=-9999.0,
tiled=False, blockysize=1, crs="EPSG:4326",
interleave="pixel")
profile.pop("compress", None)
mask = np.asarray(dem) == -9999.0
fdir = np.asarray(fdir, dtype="float32")
fdir[~np.isin(fdir, _ESRI_D8)] = -9999.0 # pysheds pits/flats: -1/-2/0
facc = np.asarray(facc, dtype="float32")
for path, out in ((fdir_path, fdir), (facc_path, facc)):
out[mask] = -9999.0
with rasterio.open(path, "w", **profile) as dst:
dst.write(out, 1)
def store_dir(bbox) -> str:
"""Shared per-basin clip store: repeated runs (and every calibration
candidate) reuse the same clipped DEM/DDM/FAM instead of re-reading the
remote COGs — faster AND immune to transient network read failures."""
from hf_data.statecache import CACHE_DIR
key = "_".join(f"{v:.3f}" for v in bbox).replace("-", "m").replace(".", "p")
d = os.path.join(CACHE_DIR, "basic", key)
os.makedirs(d, exist_ok=True)
return d
def clip_basic_data(bbox, out_dir: str, unsafe_ssl: bool | None = None) -> ClipResult:
"""Clip DEM/DDM/FAM to `bbox` (W, S, E, N in EPSG:4326) from the HF COGs.
Writes dem_clip.tif / fdir_clip.tif / facc_clip.tif into `out_dir`, all on
the same window/geotransform. Returns a ClipResult. If all three clips are
already present in `out_dir` (shared store), they are reused as-is.
"""
W, S, E, N = bbox
# local-network MITM workaround; harmless (and unset) on HF runners
if unsafe_ssl is None:
unsafe_ssl = os.name == "nt"
if unsafe_ssl:
os.environ.setdefault("GDAL_HTTP_UNSAFESSL", "YES")
os.makedirs(out_dir, exist_ok=True)
files: dict[str, str] = {}
ref_shape = None
ref_bounds = None
existing = {n: os.path.join(out_dir, n) for n in BASIC_GRIDS}
if all(os.path.exists(p) for p in existing.values()): # store hit
with rasterio.open(existing["dem_clip.tif"]) as ds:
t = ds.transform
return ClipResult(out_dir=out_dir, files=existing,
width=ds.width, height=ds.height,
bounds=(t.c, t.f + ds.height * t.e,
t.c + ds.width * t.a, t.f),
derived=False)
fetch_failed: list[str] = []
for out_name, cog in BASIC_GRIDS.items():
err = None
for attempt in range(4): # /vsicurl reads fail transiently;
try: # final tries clip a local copy
_clip_one(_cog_src(cog, force_download=attempt >= 2),
out_name, bbox, out_dir, files)
err = None
break
except Exception as e:
err = e
time.sleep(2 * (attempt + 1))
if err is not None:
if out_name == "dem_clip.tif":
raise err # DEM is required — no fallback
fetch_failed.append(out_name)
_shapes = {}
for out_name, path in files.items():
with rasterio.open(path) as ds:
_shapes[out_name] = (ds.height, ds.width)
if out_name == "dem_clip.tif":
t = ds.transform
ref_shape = (ds.height, ds.width)
ref_bounds = (t.c, t.f + ds.height * t.e,
t.c + ds.width * t.a, t.f)
for out_name, shp in _shapes.items():
if shp != ref_shape:
fetch_failed.append(out_name) # misaligned -> rederive
# fallback: if dir/acc failed to fetch, are misaligned, or look broken
# (bad D8 codes, negative accumulation), regenerate from the clipped DEM
derived = False
fdir_p = os.path.join(out_dir, "fdir_clip.tif")
facc_p = os.path.join(out_dir, "facc_clip.tif")
try:
usable = (not fetch_failed) and _dir_acc_usable(
files["fdir_clip.tif"], files["facc_clip.tif"], files["dem_clip.tif"])
except Exception:
usable = False
if not usable:
derive_dir_acc(files["dem_clip.tif"], fdir_p, facc_p)
files["fdir_clip.tif"], files["facc_clip.tif"] = fdir_p, facc_p
derived = True
return ClipResult(out_dir=out_dir, files=files,
width=ref_shape[1], height=ref_shape[0], bounds=ref_bounds,
derived=derived)
def _clip_one(src_path: str, out_name: str, bbox, out_dir: str, files: dict) -> None:
W, S, E, N = bbox
with rasterio.open(src_path) as src:
win = from_bounds(W, S, E, N, src.transform).round_offsets().round_lengths()
# standardized clip for EF5's TifGrid reader: single-band Float32,
# strip-organized, nodata=-9999, WGS84
data = src.read(1, window=win).astype("float32")
if src.nodata is not None:
data[data == float(src.nodata)] = -9999.0
transform = src.window_transform(win)
profile = src.profile.copy()
profile.update(
driver="GTiff", height=data.shape[0], width=data.shape[1],
transform=transform, tiled=False, blockysize=1,
dtype="float32", nodata=-9999.0, crs="EPSG:4326",
interleave="pixel", # contiguous — the COGs are band-
# interleaved, which EF5's reader
# (TIFFReadScanline sample arg) rejects
)
profile.pop("compress", None) # uncompressed 1-row strips, like
# EF5's own WriteFloatTifGrid output
out_path = os.path.join(out_dir, out_name)
with rasterio.open(out_path, "w", **profile) as dst:
dst.write(data, 1)
files[out_name] = out_path
if __name__ == "__main__":
import sys, time
# smoke test: Allagash gauge 01011000 basin bbox
bbox = (-69.83, 46.32, -68.33, 47.82)
out = sys.argv[1] if len(sys.argv) > 1 else "._clip_test"
t = time.time()
r = clip_basic_data(bbox, out)
print(f"clipped {r.width}x{r.height} in {time.time()-t:.1f}s -> {out}")
for name, path in r.files.items():
with rasterio.open(path) as ds:
a = ds.read(1)
nod = ds.nodata
valid = a[a != nod] if nod is not None else a
vmin = float(valid.min()) if valid.size else float("nan")
vmax = float(valid.max()) if valid.size else float("nan")
print(f" {name}: {ds.dtypes[0]} nodata={nod} range[{vmin:.1f},{vmax:.1f}]")
|