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 +43 -0
- basinkit/basin.py +474 -0
- basinkit/cache.py +185 -0
- basinkit/catalog.py +416 -0
- basinkit/cli.py +153 -0
- basinkit/clip.py +201 -0
- basinkit/delineate/__init__.py +103 -0
- basinkit/delineate/api.py +51 -0
- basinkit/delineate/dem.py +184 -0
- basinkit/delineate/hydrobasins.py +346 -0
- basinkit/exceptions.py +48 -0
- basinkit/mosaic.py +125 -0
- basinkit/sources/__init__.py +28 -0
- basinkit/sources/attributes.py +196 -0
- basinkit/sources/climate.py +358 -0
- basinkit/sources/dem.py +169 -0
- basinkit/sources/landcover.py +127 -0
- basinkit/sources/soil.py +153 -0
- basinkit/sources/stac.py +354 -0
- basinkit/sources/vectors.py +81 -0
- basinkit/sources/water.py +88 -0
- basinkit/viz.py +152 -0
- basinkit-0.1.0.dist-info/METADATA +350 -0
- basinkit-0.1.0.dist-info/RECORD +27 -0
- basinkit-0.1.0.dist-info/WHEEL +4 -0
- basinkit-0.1.0.dist-info/entry_points.txt +2 -0
- basinkit-0.1.0.dist-info/licenses/LICENSE +33 -0
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)
|