basinkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
basinkit/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """basinkit -- point to river basin to every open Earth observation layer.
2
+
3
+ Give it an outlet coordinate anywhere on Earth. It delineates the upstream
4
+ basin, then fetches DEM, land cover, soil, rainfall, surface water and satellite
5
+ imagery **clipped and masked to that polygon** -- not to its bounding box, and
6
+ not behind a login.
7
+
8
+ import basinkit as bk
9
+
10
+ basin = bk.Basin.from_point(26.87, 87.15) # Sapta Koshi at Chatara
11
+ print(basin)
12
+ dem = basin.dem()
13
+ basin.download_all("koshi/")
14
+
15
+ Every layer in the default stack is anonymous and licensed CC BY 4.0 or more
16
+ permissive. Datasets that need an account (ERA5-Land, IMERG, GloFAS) or whose
17
+ licence restricts use (MERIT Hydro, FABDEM, MSWEP, GRDC) are opt-in and
18
+ announce themselves before the first byte moves. ``basinkit.catalog.table()``
19
+ shows the whole picture.
20
+ """
21
+
22
+ from . import cache, catalog, clip, delineate, sources
23
+ from .basin import Basin
24
+ from .exceptions import (
25
+ BasinkitError,
26
+ DataSourceError,
27
+ DelineationError,
28
+ LicenseError,
29
+ MissingDependency,
30
+ NotImplementedSource,
31
+ OutletSnapError,
32
+ )
33
+
34
+ __version__ = "0.1.0"
35
+
36
+ __all__ = [
37
+ "Basin",
38
+ "catalog", "cache", "clip", "delineate", "sources",
39
+ "BasinkitError", "DelineationError", "OutletSnapError",
40
+ "DataSourceError", "LicenseError", "MissingDependency",
41
+ "NotImplementedSource",
42
+ "__version__",
43
+ ]
basinkit/basin.py ADDED
@@ -0,0 +1,474 @@
1
+ """The Basin object: one outlet in, every open layer out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from . import catalog
10
+ from .exceptions import LicenseError
11
+
12
+
13
+ class Basin:
14
+ """An upstream river basin and everything open that can be clipped to it.
15
+
16
+ Create one from an outlet coordinate and every layer method afterwards is
17
+ masked to the polygon, not to its bounding box::
18
+
19
+ import basinkit as bk
20
+
21
+ basin = bk.Basin.from_point(26.87, 87.15) # Sapta Koshi at Chatara
22
+ basin.area_km2
23
+ dem = basin.dem() # xarray, clipped + masked
24
+ lc = basin.landcover()
25
+ rain = basin.precipitation(2000, 2024) # basin-mean monthly series
26
+ basin.download_all("koshi/") # the whole default stack
27
+
28
+ Attributes
29
+ ----------
30
+ geometry : shapely geometry
31
+ Basin polygon in EPSG:4326.
32
+ provenance : dict
33
+ Which backend and which dataset version produced the polygon. This
34
+ travels with the basin so that a result is always attributable, and it
35
+ is written into every export.
36
+ """
37
+
38
+ def __init__(self, geometry, provenance: dict | None = None) -> None:
39
+ self.geometry = geometry
40
+ self.provenance = provenance or {}
41
+ self._cache: dict[str, Any] = {}
42
+
43
+ # -- constructors ------------------------------------------------------
44
+ @classmethod
45
+ def from_point(
46
+ cls, lat: float, lon: float, *, backend: str = "auto", **kwargs
47
+ ) -> Basin:
48
+ """Delineate the basin upstream of an outlet coordinate.
49
+
50
+ Parameters
51
+ ----------
52
+ backend
53
+ ``'auto'`` (default), ``'hydrobasins'``, ``'dem'`` or ``'api'``.
54
+ See :mod:`basinkit.delineate` for what each one is good at.
55
+ """
56
+ from .delineate import delineate
57
+
58
+ if not -90 <= lat <= 90 or not -180 <= lon <= 180:
59
+ raise ValueError(
60
+ f"({lat}, {lon}) is not a valid lat/lon. Note the order is "
61
+ "(lat, lon) -- swapping them is the usual cause."
62
+ )
63
+ geom, prov = delineate(lat, lon, backend=backend, **kwargs)
64
+ return cls(geom, prov)
65
+
66
+ @classmethod
67
+ def from_geometry(cls, geometry, provenance: dict | None = None) -> Basin:
68
+ """Wrap a polygon you already have (a gauge basin, an official boundary)."""
69
+ return cls(geometry, provenance or {"backend": "user-supplied"})
70
+
71
+ @classmethod
72
+ def from_file(cls, path: str | Path) -> Basin:
73
+ """Load a basin from any vector file geopandas can read."""
74
+ import geopandas as gpd
75
+
76
+ gdf = gpd.read_file(path)
77
+ if gdf.crs and gdf.crs.to_epsg() != 4326:
78
+ gdf = gdf.to_crs("EPSG:4326")
79
+ return cls(gdf.union_all(), {"backend": "file", "path": str(path)})
80
+
81
+ # -- properties --------------------------------------------------------
82
+ @property
83
+ def area_km2(self) -> float:
84
+ """Basin area via an equal-area projection centred on the basin itself."""
85
+ if "area" not in self._cache:
86
+ from .clip import basin_area_km2
87
+
88
+ self._cache["area"] = basin_area_km2(self.geometry)
89
+ return self._cache["area"]
90
+
91
+ @property
92
+ def bounds(self) -> tuple[float, float, float, float]:
93
+ return self.geometry.bounds
94
+
95
+ @property
96
+ def centroid(self) -> tuple[float, float]:
97
+ c = self.geometry.centroid
98
+ return (c.y, c.x)
99
+
100
+ @property
101
+ def bbox_efficiency(self) -> float:
102
+ """Basin area as a fraction of its bounding-box area.
103
+
104
+ This is the number that justifies polygon clipping. A compact basin
105
+ scores near 0.7; a long dendritic one can drop below 0.25, meaning a
106
+ bbox-based download wastes three quarters of everything it transfers
107
+ and biases every basin average with a neighbour's pixels.
108
+ """
109
+ from shapely.geometry import box
110
+
111
+ from .clip import basin_area_km2
112
+
113
+ return self.area_km2 / basin_area_km2(box(*self.geometry.bounds))
114
+
115
+ def __repr__(self) -> str:
116
+ backend = self.provenance.get("backend", "?")
117
+ lat, lon = self.centroid
118
+ return (
119
+ f"<Basin area={self.area_km2:,.0f} km2 "
120
+ f"centroid=({lat:.3f}, {lon:.3f}) backend={backend!r}>"
121
+ )
122
+
123
+ # -- layers ------------------------------------------------------------
124
+ def dem(self, product: str = "cop30", **kwargs):
125
+ """Elevation, clipped and masked to the basin."""
126
+ from .sources.dem import dem
127
+
128
+ return dem(self.geometry, product=product, **kwargs)
129
+
130
+ def landcover(self, year: int = 2021, source: str = "worldcover", **kwargs):
131
+ """Land cover. ``worldcover`` (10 m, 2020/2021) or ``esri`` (annual 2017-2024)."""
132
+ from .sources.landcover import esri_lulc, worldcover
133
+
134
+ if source == "worldcover":
135
+ return worldcover(self.geometry, year=year, **kwargs)
136
+ if source == "esri":
137
+ return esri_lulc(self.geometry, year=year, **kwargs)
138
+ raise ValueError(f"Unknown land cover source {source!r}: use 'worldcover' or 'esri'.")
139
+
140
+ def soil(self, prop: str = "clay", depth: str = "0-5cm", **kwargs):
141
+ """A SoilGrids property. See :data:`basinkit.sources.soil.PROPERTIES`."""
142
+ from .sources.soil import soilgrids
143
+
144
+ return soilgrids(self.geometry, prop=prop, depth=depth, **kwargs)
145
+
146
+ def available_water_capacity(self, depth: str = "0-5cm"):
147
+ """Plant-available water capacity (field capacity minus wilting point)."""
148
+ from .sources.soil import available_water_capacity
149
+
150
+ return available_water_capacity(self.geometry, depth=depth)
151
+
152
+ def precipitation(self, start=2000, end=None, source: str = "chirps", **kwargs):
153
+ """Basin-mean rainfall time series. ``chirps``, ``persiann`` or ``terraclimate``."""
154
+ from .sources.climate import chirps, persiann, terraclimate
155
+
156
+ if source == "chirps":
157
+ return chirps(self.geometry, start, end, **kwargs)
158
+ if source == "persiann":
159
+ return persiann(self.geometry, str(start), end, **kwargs)
160
+ if source == "terraclimate":
161
+ return terraclimate(self.geometry, ("ppt",), int(start), end, **kwargs)
162
+ raise ValueError(
163
+ f"Unknown precipitation source {source!r}: use 'chirps', 'persiann' "
164
+ "or 'terraclimate'."
165
+ )
166
+
167
+ def water_balance(self, start: int = 2000, end: int | None = None):
168
+ """Monthly P / AET / PET / Q / soil-moisture balance from TerraClimate."""
169
+ from .sources.climate import water_balance
170
+
171
+ return water_balance(self.geometry, start, end)
172
+
173
+ def surface_water(self, layer: str = "occurrence", **kwargs):
174
+ """JRC Global Surface Water: a pre-reduced 37-year Landsat water stack."""
175
+ from .sources.water import global_surface_water
176
+
177
+ return global_surface_water(self.geometry, layer=layer, **kwargs)
178
+
179
+ def attributes(self, prefixes: tuple[str, ...] | None = None, **kwargs):
180
+ """281 pre-computed BasinATLAS attributes for this basin.
181
+
182
+ The row returned belongs to the outlet's HydroBASINS unit, and its
183
+ ``_u`` columns are already aggregated over everything upstream -- so
184
+ this characterises the whole catchment without touching a raster.
185
+
186
+ Costs one 2.7 GB download the first time, then nothing.
187
+ """
188
+ from .sources.attributes import describe, hydroatlas
189
+
190
+ hybas_id = self.provenance.get("outlet_hybas_id")
191
+ if hybas_id is None:
192
+ raise ValueError(
193
+ "BasinATLAS is keyed by HydroBASINS id, which only the "
194
+ "'hydrobasins' backend records. Re-delineate with "
195
+ "backend='hydrobasins', or pass a geometry to "
196
+ "basinkit.sources.attributes.hydroatlas() directly."
197
+ )
198
+ gdf = hydroatlas(hybas_id=hybas_id, prefixes=prefixes, **kwargs)
199
+ return describe(gdf.iloc[0])
200
+
201
+ def rivers(self, min_order: int = 0, **kwargs):
202
+ """HydroRIVERS reaches inside the basin, with discharge and stream order."""
203
+ from .sources.vectors import hydrorivers
204
+
205
+ return hydrorivers(self.geometry, min_order=min_order, **kwargs)
206
+
207
+ def lakes(self, min_area_km2: float = 0.0, **kwargs):
208
+ """HydroLAKES water bodies inside the basin."""
209
+ from .sources.vectors import hydrolakes
210
+
211
+ return hydrolakes(self.geometry, min_area_km2=min_area_km2, **kwargs)
212
+
213
+ def sentinel2(self, start: str, end: str, *, cloud_cover: float = 20,
214
+ bands: list[str] | None = None, composite: str | None = "median",
215
+ **kwargs):
216
+ """Sentinel-2 L2A over the basin, cloud-filtered and optionally composited."""
217
+ from .sources.stac import composite as reduce_time
218
+ from .sources.stac import stac_search, stac_stack
219
+
220
+ items = stac_search(
221
+ "sentinel2", geometry=self.geometry, start=start, end=end,
222
+ cloud_cover=cloud_cover, **kwargs
223
+ )
224
+ ds = stac_stack(items, self.geometry, bands=bands or ["blue", "green", "red", "nir"],
225
+ collection="sentinel2")
226
+ return reduce_time(ds, composite) if composite else ds
227
+
228
+ def landsat(self, start: str, end: str, *, cloud_cover: float = 20,
229
+ bands: list[str] | None = None, composite: str | None = "median",
230
+ **kwargs):
231
+ """Landsat Collection 2 Level-2 over the basin (1982 to present)."""
232
+ from .sources.stac import composite as reduce_time
233
+ from .sources.stac import stac_search, stac_stack
234
+
235
+ items = stac_search(
236
+ "landsat", geometry=self.geometry, start=start, end=end,
237
+ cloud_cover=cloud_cover, **kwargs
238
+ )
239
+ ds = stac_stack(items, self.geometry, bands=bands or ["blue", "green", "red", "nir08"],
240
+ collection="landsat")
241
+ return reduce_time(ds, composite) if composite else ds
242
+
243
+ def sentinel1(self, start: str, end: str, *, bands: list[str] | None = None,
244
+ composite: str | None = "median", **kwargs):
245
+ """Sentinel-1 RTC: terrain-corrected radar, so it works through cloud."""
246
+ from .sources.stac import composite as reduce_time
247
+ from .sources.stac import stac_search, stac_stack
248
+
249
+ items = stac_search(
250
+ "sentinel1_rtc", geometry=self.geometry, start=start, end=end, **kwargs
251
+ )
252
+ ds = stac_stack(items, self.geometry, bands=bands or ["vv", "vh"],
253
+ collection="sentinel1_rtc")
254
+ return reduce_time(ds, composite) if composite else ds
255
+
256
+ # -- summaries ---------------------------------------------------------
257
+ def terrain_stats(self) -> dict:
258
+ """Elevation, relief and mean slope: the standard morphometry."""
259
+ import numpy as np
260
+
261
+ elev = self.dem()
262
+ vals = np.asarray(elev.values, dtype="float64")
263
+ vals = vals[np.isfinite(vals)]
264
+ if vals.size == 0:
265
+ return {}
266
+
267
+ res = abs(float(elev.rio.resolution()[0]))
268
+ lat = self.centroid[0]
269
+ cell_m = res * 111_320 * np.cos(np.deg2rad(lat))
270
+ gy, gx = np.gradient(np.nan_to_num(np.asarray(elev.values, dtype="float64")))
271
+ slope = np.degrees(np.arctan(np.hypot(gx, gy) / max(cell_m, 1e-6)))
272
+
273
+ return {
274
+ "area_km2": round(self.area_km2, 2),
275
+ "elev_min_m": round(float(vals.min()), 1),
276
+ "elev_max_m": round(float(vals.max()), 1),
277
+ "elev_mean_m": round(float(vals.mean()), 1),
278
+ "relief_m": round(float(vals.max() - vals.min()), 1),
279
+ "slope_mean_deg": round(float(np.nanmean(slope)), 2),
280
+ "bbox_efficiency": round(self.bbox_efficiency, 3),
281
+ }
282
+
283
+ def summary(self, *, terrain: bool = True, landcover: bool = True) -> dict:
284
+ """A one-call characterisation of the basin."""
285
+ out: dict[str, Any] = {
286
+ "area_km2": round(self.area_km2, 2),
287
+ "centroid_lat_lon": [round(v, 5) for v in self.centroid],
288
+ "bounds": [round(v, 5) for v in self.bounds],
289
+ "bbox_efficiency": round(self.bbox_efficiency, 3),
290
+ "provenance": self.provenance,
291
+ }
292
+ if terrain:
293
+ try:
294
+ out["terrain"] = self.terrain_stats()
295
+ except Exception as exc:
296
+ out["terrain"] = {"error": str(exc)}
297
+ if landcover:
298
+ try:
299
+ from .sources.landcover import class_fractions
300
+
301
+ out["landcover_fractions"] = class_fractions(self.landcover())
302
+ except Exception as exc:
303
+ out["landcover_fractions"] = {"error": str(exc)}
304
+ return out
305
+
306
+ # -- licensing ---------------------------------------------------------
307
+ def license_report(self, layers: tuple[str, ...] | None = None) -> str:
308
+ """Attribution and licence text for the layers you used.
309
+
310
+ Print this into your methods section. Every layer basinkit fetches by
311
+ default is CC BY 4.0 or more permissive, which means it can be
312
+ redistributed and used commercially -- but only if it is attributed.
313
+ """
314
+ keys = layers or catalog.DEFAULT_STACK
315
+ lines = ["Data sources and licences", "=" * 26, ""]
316
+ for key in keys:
317
+ try:
318
+ ds = catalog.get(key)
319
+ except KeyError:
320
+ continue
321
+ lines.append(f"{ds.name}")
322
+ lines.append(f" Licence : {ds.license}")
323
+ lines.append(f" Access : {ds.route}")
324
+ if ds.citation:
325
+ lines.append(f" Cite : {ds.citation}")
326
+ if not ds.commercial_ok:
327
+ lines.append(" WARNING : commercial use not permitted")
328
+ if not ds.redistributable:
329
+ lines.append(" WARNING : redistribution not permitted")
330
+ lines.append("")
331
+ return "\n".join(lines)
332
+
333
+ @staticmethod
334
+ def check_license(key: str, *, commercial: bool = False,
335
+ redistribute: bool = False) -> None:
336
+ """Raise if a dataset's licence forbids the intended use."""
337
+ ds = catalog.get(key)
338
+ if commercial and not ds.commercial_ok:
339
+ raise LicenseError(
340
+ f"{ds.name} is licensed {ds.license}, which forbids commercial use. "
341
+ f"{ds.notes}"
342
+ )
343
+ if redistribute and not ds.redistributable:
344
+ raise LicenseError(
345
+ f"{ds.name} may not be redistributed under {ds.license}. {ds.notes}"
346
+ )
347
+
348
+ # -- export ------------------------------------------------------------
349
+ def to_geojson(self, path: str | Path | None = None) -> str:
350
+ import geopandas as gpd
351
+
352
+ gdf = gpd.GeoDataFrame(
353
+ {"area_km2": [self.area_km2],
354
+ "backend": [self.provenance.get("backend", "")],
355
+ "source": [self.provenance.get("source_dataset", "")]},
356
+ geometry=[self.geometry], crs="EPSG:4326",
357
+ )
358
+ if path:
359
+ gdf.to_file(path, driver="GeoJSON")
360
+ return str(path)
361
+ return gdf.to_json()
362
+
363
+ def download_all(
364
+ self,
365
+ outdir: str | Path,
366
+ layers: tuple[str, ...] = ("dem", "landcover", "soil", "surface_water",
367
+ "precipitation", "rivers"),
368
+ *,
369
+ start: int = 2000,
370
+ end: int | None = None,
371
+ progress: bool = True,
372
+ ) -> dict:
373
+ """Fetch the default stack and write it to ``outdir``.
374
+
375
+ This is the "give me everything" button. Each layer is attempted
376
+ independently, so one failing source (a polar basin with no CHIRPS, say)
377
+ does not abort the rest -- failures are recorded in the manifest
378
+ alongside the successes.
379
+ """
380
+ outdir = Path(outdir)
381
+ outdir.mkdir(parents=True, exist_ok=True)
382
+
383
+ manifest: dict[str, Any] = {
384
+ "basin": {
385
+ "area_km2": round(self.area_km2, 2),
386
+ "bounds": list(self.bounds),
387
+ "centroid_lat_lon": list(self.centroid),
388
+ },
389
+ "provenance": self.provenance,
390
+ "layers": {},
391
+ "failed": {},
392
+ }
393
+
394
+ self.to_geojson(outdir / "basin.geojson")
395
+ manifest["layers"]["basin"] = "basin.geojson"
396
+
397
+ def _write_raster(da, name: str) -> str:
398
+ fp = outdir / f"{name}.tif"
399
+ # Declare nodata in the file header. The array is already masked
400
+ # outside the basin, but without a declared nodata value QGIS and
401
+ # ArcGIS paint that area solid black instead of transparent, and
402
+ # rasterio's masked read returns no mask at all -- so a correctly
403
+ # clipped raster looks and behaves like an unclipped one the moment
404
+ # it leaves Python.
405
+ import numpy as np
406
+
407
+ if da.rio.nodata is None:
408
+ if np.issubdtype(da.dtype, np.floating):
409
+ da = da.rio.write_nodata(np.nan, encoded=False)
410
+ else:
411
+ da = da.rio.write_nodata(0, encoded=False)
412
+ da.rio.to_raster(fp, compress="deflate", tiled=True)
413
+ return fp.name
414
+
415
+ jobs = {
416
+ "dem": lambda: _write_raster(self.dem(progress=progress), "dem"),
417
+ "landcover": lambda: _write_raster(
418
+ self.landcover(progress=progress), "landcover"
419
+ ),
420
+ "soil": lambda: _write_raster(self.soil("clay"), "soil_clay_0-5cm"),
421
+ "surface_water": lambda: _write_raster(
422
+ self.surface_water(progress=progress), "surface_water_occurrence"
423
+ ),
424
+ "precipitation": lambda: self._write_series(
425
+ self.precipitation(start, end), outdir, "precipitation_chirps"
426
+ ),
427
+ "rivers": lambda: self._write_vector(
428
+ self.rivers(progress=progress), outdir, "rivers"
429
+ ),
430
+ "lakes": lambda: self._write_vector(
431
+ self.lakes(progress=progress), outdir, "lakes"
432
+ ),
433
+ }
434
+
435
+ for name in layers:
436
+ if name not in jobs:
437
+ manifest["failed"][name] = f"unknown layer {name!r}"
438
+ continue
439
+ try:
440
+ manifest["layers"][name] = jobs[name]()
441
+ except Exception as exc:
442
+ manifest["failed"][name] = f"{type(exc).__name__}: {exc}"
443
+
444
+ (outdir / "LICENSES.txt").write_text(self.license_report())
445
+ manifest["layers"]["licenses"] = "LICENSES.txt"
446
+ (outdir / "manifest.json").write_text(json.dumps(manifest, indent=2, default=str))
447
+ return manifest
448
+
449
+ @staticmethod
450
+ def _write_series(da, outdir: Path, name: str) -> str:
451
+ fp = outdir / f"{name}.csv"
452
+ da.to_dataframe().to_csv(fp)
453
+ return fp.name
454
+
455
+ @staticmethod
456
+ def _write_vector(gdf, outdir: Path, name: str) -> str:
457
+ fp = outdir / f"{name}.gpkg"
458
+ if len(gdf) == 0:
459
+ return f"{name}: none within basin"
460
+ gdf.to_file(fp, driver="GPKG")
461
+ return fp.name
462
+
463
+ # -- viz ---------------------------------------------------------------
464
+ def explore(self, **kwargs):
465
+ """Interactive map of the basin. Needs ``pip install 'basinkit[viz]'``."""
466
+ from .viz import explore
467
+
468
+ return explore(self, **kwargs)
469
+
470
+ def plot(self, **kwargs):
471
+ """Static matplotlib figure: hypsometry, boundary and river network."""
472
+ from .viz import plot
473
+
474
+ return plot(self, **kwargs)