rle-python 0.2.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.
- rle/core/__init__.py +77 -0
- rle/core/_entrypoints.py +51 -0
- rle/core/aoo.py +815 -0
- rle/core/aoo_grid.py +54 -0
- rle/core/cli.py +49 -0
- rle/core/ecosystem_codes.py +108 -0
- rle/core/ecosystems.py +690 -0
- rle/core/eoo.py +243 -0
- rle/core/registry.py +62 -0
- rle/core/rle.py +71 -0
- rle/core/viz.py +183 -0
- rle_python-0.2.0.dist-info/METADATA +96 -0
- rle_python-0.2.0.dist-info/RECORD +16 -0
- rle_python-0.2.0.dist-info/WHEEL +4 -0
- rle_python-0.2.0.dist-info/entry_points.txt +5 -0
- rle_python-0.2.0.dist-info/licenses/LICENSE +201 -0
rle/core/ecosystems.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
"""Ecosystem distribution data sources for RLE assessments.
|
|
2
|
+
|
|
3
|
+
Provides the ``Ecosystems`` class hierarchy for loading ecosystem data from
|
|
4
|
+
local and cloud-file backends (GeoJSON, GeoParquet — including ``gs://`` and
|
|
5
|
+
``s3://`` via fsspec — and COGs).
|
|
6
|
+
|
|
7
|
+
Earth Engine backends live in the optional ``rle-python-gee`` package
|
|
8
|
+
(``rle.gee``). Construct them explicitly, e.g.::
|
|
9
|
+
|
|
10
|
+
from rle.gee import GeeEcosystems
|
|
11
|
+
eco = GeeEcosystems("projects/my-project/assets/ecosystems")
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _natural_key(s: str) -> list:
|
|
21
|
+
"""Sort key that orders numeric parts numerically (e.g. T1.1.2 before T1.1.10)."""
|
|
22
|
+
return [int(part) if part.isdigit() else part.lower()
|
|
23
|
+
for part in re.split(r'(\d+)', s)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_parquet(gdf, path) -> None:
|
|
27
|
+
"""Write a GeoDataFrame to a GeoParquet file (local or gs://)."""
|
|
28
|
+
path_str = str(path)
|
|
29
|
+
if path_str.startswith("gs://"):
|
|
30
|
+
bucket = path_str.split("/")[2]
|
|
31
|
+
try:
|
|
32
|
+
gdf.to_parquet(path_str)
|
|
33
|
+
except FileNotFoundError as exc:
|
|
34
|
+
msg = str(exc)
|
|
35
|
+
if "does not exist" in msg:
|
|
36
|
+
raise FileNotFoundError(
|
|
37
|
+
f"GCS bucket '{bucket}' not found. "
|
|
38
|
+
f"Create it with: gcloud storage buckets create gs://{bucket}"
|
|
39
|
+
) from None
|
|
40
|
+
raise FileNotFoundError(
|
|
41
|
+
f"Failed to write to {path_str!r}: {msg}"
|
|
42
|
+
) from None
|
|
43
|
+
except ImportError:
|
|
44
|
+
raise ImportError(
|
|
45
|
+
"The 'gcsfs' package is required to write to GCS. "
|
|
46
|
+
"Install it with: pip install rle-python[gcs]"
|
|
47
|
+
) from None
|
|
48
|
+
else:
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
Path(path_str).parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
gdf.to_parquet(path_str)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EcosystemKind(Enum):
|
|
55
|
+
VECTOR_LOCAL = "vector_local"
|
|
56
|
+
RASTER_LOCAL = "raster_local"
|
|
57
|
+
EE_FEATURE_COLLECTION = "ee_fc"
|
|
58
|
+
EE_IMAGE = "ee_image"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Ecosystems(ABC):
|
|
62
|
+
"""Base class for ecosystem distribution datasets."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, data, *, ecosystem_column: str | None = None,
|
|
65
|
+
ecosystem_name_column: str | None = None,
|
|
66
|
+
functional_group_column: str | None = None):
|
|
67
|
+
self._data = data
|
|
68
|
+
self._cached = None
|
|
69
|
+
self.ecosystem_column = ecosystem_column
|
|
70
|
+
self.ecosystem_name_column = ecosystem_name_column
|
|
71
|
+
self.functional_group_column = functional_group_column
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def kind(self) -> EcosystemKind: ...
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def _load(self) -> Any: ...
|
|
79
|
+
|
|
80
|
+
def load(self) -> Any:
|
|
81
|
+
"""Load and cache the ecosystem data. Returns the native object."""
|
|
82
|
+
if self._cached is None:
|
|
83
|
+
self._cached = self._load()
|
|
84
|
+
return self._cached
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def geometry(self):
|
|
88
|
+
"""Return the geometry column of the loaded data."""
|
|
89
|
+
data = self.load()
|
|
90
|
+
return data.geometry
|
|
91
|
+
|
|
92
|
+
def head(self, n: int = 5):
|
|
93
|
+
"""Return the first n rows of the loaded data."""
|
|
94
|
+
data = self.load()
|
|
95
|
+
if hasattr(data, 'head'):
|
|
96
|
+
return data.head(n)
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
def size(self) -> int:
|
|
100
|
+
"""Return the number of features."""
|
|
101
|
+
data = self.load()
|
|
102
|
+
if hasattr(data, '__len__'):
|
|
103
|
+
return len(data)
|
|
104
|
+
raise NotImplementedError(
|
|
105
|
+
f"size not supported for {self.kind.value}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def limit(self, n: int) -> "Ecosystems":
|
|
109
|
+
"""Return a new Ecosystems with only the first n features."""
|
|
110
|
+
data = self.load()
|
|
111
|
+
if hasattr(data, 'iloc'):
|
|
112
|
+
return EcosystemsGeoDataFrame(data.iloc[:n], ecosystem_column=self.ecosystem_column,
|
|
113
|
+
ecosystem_name_column=self.ecosystem_name_column,
|
|
114
|
+
functional_group_column=self.functional_group_column)
|
|
115
|
+
raise NotImplementedError(
|
|
116
|
+
f"limit not supported for {self.kind.value}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def unique_ecosystems(self) -> list[str]:
|
|
120
|
+
"""Return a naturally sorted list of unique ecosystem values."""
|
|
121
|
+
if self.ecosystem_column is None:
|
|
122
|
+
raise ValueError("ecosystem_column is not set")
|
|
123
|
+
data = self.load()
|
|
124
|
+
if hasattr(data, '__getitem__'):
|
|
125
|
+
return sorted(data[self.ecosystem_column].unique(), key=_natural_key)
|
|
126
|
+
raise NotImplementedError(
|
|
127
|
+
f"unique_ecosystems not supported for {self.kind.value}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def unique_functional_groups(self) -> list[str]:
|
|
131
|
+
"""Return a naturally sorted list of unique functional group values."""
|
|
132
|
+
if self.functional_group_column is None:
|
|
133
|
+
raise ValueError("functional_group_column is not set")
|
|
134
|
+
data = self.load()
|
|
135
|
+
if hasattr(data, '__getitem__'):
|
|
136
|
+
return sorted(data[self.functional_group_column].unique(), key=_natural_key)
|
|
137
|
+
raise NotImplementedError(
|
|
138
|
+
f"unique_functional_groups not supported for {self.kind.value}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def ecosystem_name(self, code: str) -> str:
|
|
142
|
+
"""Look up the human-readable name for an ecosystem code.
|
|
143
|
+
|
|
144
|
+
Requires ``ecosystem_name_column`` to be set.
|
|
145
|
+
"""
|
|
146
|
+
if self.ecosystem_name_column is None:
|
|
147
|
+
raise ValueError("ecosystem_name_column is not set")
|
|
148
|
+
if self.ecosystem_column is None:
|
|
149
|
+
raise ValueError("ecosystem_column is not set")
|
|
150
|
+
data = self.load()
|
|
151
|
+
match = data.loc[data[self.ecosystem_column] == code, self.ecosystem_name_column]
|
|
152
|
+
if match.empty:
|
|
153
|
+
raise KeyError(f"Ecosystem code {code!r} not found")
|
|
154
|
+
return match.iloc[0]
|
|
155
|
+
|
|
156
|
+
def ecosystem_names(self) -> dict[str, str]:
|
|
157
|
+
"""Return a mapping of ecosystem codes to their names, sorted naturally by code.
|
|
158
|
+
|
|
159
|
+
Requires ``ecosystem_name_column`` to be set.
|
|
160
|
+
"""
|
|
161
|
+
if self.ecosystem_name_column is None:
|
|
162
|
+
raise ValueError("ecosystem_name_column is not set")
|
|
163
|
+
if self.ecosystem_column is None:
|
|
164
|
+
raise ValueError("ecosystem_column is not set")
|
|
165
|
+
data = self.load()
|
|
166
|
+
pairs = data.drop_duplicates(subset=self.ecosystem_column)
|
|
167
|
+
mapping = dict(zip(
|
|
168
|
+
pairs[self.ecosystem_column],
|
|
169
|
+
pairs[self.ecosystem_name_column],
|
|
170
|
+
))
|
|
171
|
+
return {k: mapping[k] for k in sorted(mapping, key=_natural_key)}
|
|
172
|
+
|
|
173
|
+
def filter(self, pattern: str, *, regex: bool = False) -> "Ecosystems":
|
|
174
|
+
"""Return a new Ecosystems containing only features matching the given value.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
pattern: Exact value or regex pattern to match against the ecosystem column.
|
|
178
|
+
regex: If True, treat pattern as a regular expression.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
A new Ecosystems object with only the matching features.
|
|
182
|
+
"""
|
|
183
|
+
if self.ecosystem_column is None:
|
|
184
|
+
raise ValueError("ecosystem_column is not set")
|
|
185
|
+
data = self.load()
|
|
186
|
+
if not hasattr(data, '__getitem__'):
|
|
187
|
+
raise NotImplementedError(
|
|
188
|
+
f"filter not supported for {self.kind.value}"
|
|
189
|
+
)
|
|
190
|
+
if regex:
|
|
191
|
+
mask = data[self.ecosystem_column].str.match(pattern)
|
|
192
|
+
else:
|
|
193
|
+
mask = data[self.ecosystem_column] == pattern
|
|
194
|
+
return EcosystemsGeoDataFrame(data[mask], ecosystem_column=self.ecosystem_column,
|
|
195
|
+
ecosystem_name_column=self.ecosystem_name_column,
|
|
196
|
+
functional_group_column=self.functional_group_column)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def aoo(self) -> int:
|
|
200
|
+
"""AOO cell count for this ecosystem. Cached after first access."""
|
|
201
|
+
if not hasattr(self, '_aoo'):
|
|
202
|
+
from rle.core.aoo import make_aoo_grid, slugify_ecosystem_name
|
|
203
|
+
|
|
204
|
+
ecosystems = self.unique_ecosystems()
|
|
205
|
+
if len(ecosystems) != 1:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"aoo requires exactly one ecosystem, "
|
|
208
|
+
f"but found {len(ecosystems)}. Filter first with "
|
|
209
|
+
f".filter('ecosystem_name')."
|
|
210
|
+
)
|
|
211
|
+
ecosystem_code = ecosystems[0]
|
|
212
|
+
column = slugify_ecosystem_name(ecosystem_code)
|
|
213
|
+
|
|
214
|
+
aoo_grid = make_aoo_grid(self).compute()
|
|
215
|
+
filtered = aoo_grid.filter_by_ecosystem(ecosystem_code)
|
|
216
|
+
gdf = filtered.grid_cells.sort_values(by=column)
|
|
217
|
+
gdf["cumulative_fraction"] = gdf[column].cumsum()
|
|
218
|
+
total = gdf["cumulative_fraction"].iloc[-1]
|
|
219
|
+
gdf["cumulative_proportion"] = gdf["cumulative_fraction"] / total
|
|
220
|
+
self._aoo = int(len(gdf[gdf["cumulative_proportion"] > 0.01]))
|
|
221
|
+
return self._aoo
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def eoo(self) -> float:
|
|
225
|
+
"""EOO area in km². Cached after first access."""
|
|
226
|
+
if not hasattr(self, '_eoo'):
|
|
227
|
+
from rle.core.eoo import make_eoo
|
|
228
|
+
self._eoo = make_eoo(self).compute().area_km2
|
|
229
|
+
return self._eoo
|
|
230
|
+
|
|
231
|
+
def to_raster(
|
|
232
|
+
self,
|
|
233
|
+
path,
|
|
234
|
+
*,
|
|
235
|
+
crs,
|
|
236
|
+
scale,
|
|
237
|
+
mode: str = "index",
|
|
238
|
+
oversampling: int = 10,
|
|
239
|
+
nodata=None,
|
|
240
|
+
) -> dict[int, str]:
|
|
241
|
+
"""Rasterize ecosystem polygons to a Cloud Optimized GeoTIFF.
|
|
242
|
+
|
|
243
|
+
Two modes are supported:
|
|
244
|
+
|
|
245
|
+
* ``mode="index"`` (default): single-band integer raster where each
|
|
246
|
+
pixel holds the 1-based index of the ecosystem covering the
|
|
247
|
+
pixel's center (rasterio's default ``all_touched=False``
|
|
248
|
+
semantics). Indices follow the natural-sort order of
|
|
249
|
+
``unique_ecosystems()``. Where polygons overlap at a pixel
|
|
250
|
+
center, the naturally-later code wins (rasterio's default
|
|
251
|
+
``MergeAlg.replace`` combined with our deterministic iteration
|
|
252
|
+
order). Default nodata is the maximum value of the chosen
|
|
253
|
+
output dtype (255 / 65535 / 4294967295).
|
|
254
|
+
|
|
255
|
+
* ``mode="fraction"``: multi-band float32 raster with one band per
|
|
256
|
+
ecosystem (also in natural-sort order). Each band stores the
|
|
257
|
+
fraction of the pixel covered by that ecosystem in
|
|
258
|
+
``[0.0, 1.0]``, computed by rasterizing a binary mask at
|
|
259
|
+
``oversampling`` × per axis and averaging the resulting
|
|
260
|
+
sub-pixels. Each band's description tag is set to its ecosystem
|
|
261
|
+
code. Default nodata is ``NaN``.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
path: Output COG path.
|
|
265
|
+
crs: Target CRS (EPSG code, WKT, or pyproj CRS).
|
|
266
|
+
scale: Pixel size in CRS units (meters for projected CRS).
|
|
267
|
+
mode: ``"index"`` or ``"fraction"``.
|
|
268
|
+
oversampling: Sub-pixel factor per axis for ``"fraction"``
|
|
269
|
+
mode (1..255). Ignored in ``"index"`` mode.
|
|
270
|
+
nodata: Nodata sentinel. Defaults to dtype-max in ``"index"``
|
|
271
|
+
mode and ``NaN`` in ``"fraction"`` mode.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Mapping of 1-based index (= band number in fraction mode) ->
|
|
275
|
+
ecosystem code.
|
|
276
|
+
"""
|
|
277
|
+
if self.kind != EcosystemKind.VECTOR_LOCAL:
|
|
278
|
+
raise NotImplementedError(
|
|
279
|
+
f"to_raster not supported for {self.kind.value}"
|
|
280
|
+
)
|
|
281
|
+
if mode not in ("index", "fraction"):
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"mode must be 'index' or 'fraction', got {mode!r}"
|
|
284
|
+
)
|
|
285
|
+
if mode == "fraction" and not (1 <= oversampling <= 255):
|
|
286
|
+
raise ValueError("oversampling must be in [1, 255]")
|
|
287
|
+
|
|
288
|
+
import json
|
|
289
|
+
import math
|
|
290
|
+
from pathlib import Path
|
|
291
|
+
import numpy as np
|
|
292
|
+
import rasterio
|
|
293
|
+
from rasterio.features import rasterize as _rio_rasterize
|
|
294
|
+
from rasterio.transform import from_origin
|
|
295
|
+
import shapely
|
|
296
|
+
|
|
297
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
|
|
299
|
+
# ---- Load + validate + reproject ----
|
|
300
|
+
gdf = self.to_geodataframe()
|
|
301
|
+
if gdf.empty:
|
|
302
|
+
raise ValueError("Cannot rasterize empty ecosystem dataset")
|
|
303
|
+
if gdf.crs is None:
|
|
304
|
+
raise ValueError("Input GeoDataFrame has no CRS set")
|
|
305
|
+
gdf = gdf.copy()
|
|
306
|
+
gdf["geometry"] = shapely.make_valid(gdf.geometry)
|
|
307
|
+
gdf = gdf.to_crs(crs)
|
|
308
|
+
gdf = gdf[~(gdf.geometry.is_empty | gdf.geometry.isna())]
|
|
309
|
+
if gdf.empty:
|
|
310
|
+
raise ValueError("All geometries dropped after reprojection")
|
|
311
|
+
|
|
312
|
+
codes = self.unique_ecosystems() # naturally sorted
|
|
313
|
+
n = len(codes)
|
|
314
|
+
if n == 0:
|
|
315
|
+
raise ValueError("No ecosystems to rasterize")
|
|
316
|
+
code_to_index = {c: i for i, c in enumerate(codes, start=1)}
|
|
317
|
+
mapping: dict[int, str] = {i: c for i, c in enumerate(codes, start=1)}
|
|
318
|
+
ecosystem_col = self.ecosystem_column
|
|
319
|
+
|
|
320
|
+
# ---- Snap target grid to pixel boundaries ----
|
|
321
|
+
minx, miny, maxx, maxy = gdf.total_bounds
|
|
322
|
+
minx = float(np.floor(minx / scale) * scale)
|
|
323
|
+
miny = float(np.floor(miny / scale) * scale)
|
|
324
|
+
maxx = float(np.ceil(maxx / scale) * scale)
|
|
325
|
+
maxy = float(np.ceil(maxy / scale) * scale)
|
|
326
|
+
W = int(round((maxx - minx) / scale))
|
|
327
|
+
H = int(round((maxy - miny) / scale))
|
|
328
|
+
transform = from_origin(minx, maxy, scale, scale)
|
|
329
|
+
|
|
330
|
+
# ---- Mode dispatch ----
|
|
331
|
+
if mode == "index":
|
|
332
|
+
# dtype: indices 1..n + reserved nodata at dtype_max
|
|
333
|
+
if n < 255:
|
|
334
|
+
dtype = np.uint8
|
|
335
|
+
elif n < 65535:
|
|
336
|
+
dtype = np.uint16
|
|
337
|
+
elif n < 4294967295:
|
|
338
|
+
dtype = np.uint32
|
|
339
|
+
else:
|
|
340
|
+
raise ValueError(
|
|
341
|
+
f"Too many ecosystems ({n}) for to_raster"
|
|
342
|
+
)
|
|
343
|
+
dtype_str = np.dtype(dtype).name
|
|
344
|
+
dtype_max = int(np.iinfo(dtype).max)
|
|
345
|
+
if nodata is None:
|
|
346
|
+
resolved_nodata = dtype_max
|
|
347
|
+
else:
|
|
348
|
+
resolved_nodata = int(nodata)
|
|
349
|
+
if not (0 <= resolved_nodata <= dtype_max):
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"nodata={nodata} out of range for {dtype_str}"
|
|
352
|
+
)
|
|
353
|
+
if 1 <= resolved_nodata <= n:
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f"nodata={nodata} collides with valid ecosystem "
|
|
356
|
+
f"index 1..{n}"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# All shapes in one rasterize call. Natural-sort order combined
|
|
360
|
+
# with the default MergeAlg.replace means later codes win at
|
|
361
|
+
# overlapping pixel centers.
|
|
362
|
+
shapes = []
|
|
363
|
+
for code in codes:
|
|
364
|
+
idx = code_to_index[code]
|
|
365
|
+
for geom in gdf.loc[gdf[ecosystem_col] == code, "geometry"]:
|
|
366
|
+
if geom is None or geom.is_empty:
|
|
367
|
+
continue
|
|
368
|
+
shapes.append((geom, idx))
|
|
369
|
+
arr = _rio_rasterize(
|
|
370
|
+
shapes=shapes,
|
|
371
|
+
out_shape=(H, W),
|
|
372
|
+
transform=transform,
|
|
373
|
+
fill=resolved_nodata,
|
|
374
|
+
dtype=dtype_str,
|
|
375
|
+
all_touched=False,
|
|
376
|
+
)
|
|
377
|
+
count = 1
|
|
378
|
+
else: # mode == "fraction"
|
|
379
|
+
if nodata is None:
|
|
380
|
+
resolved_nodata = float("nan")
|
|
381
|
+
else:
|
|
382
|
+
resolved_nodata = float(nodata)
|
|
383
|
+
if not (math.isnan(resolved_nodata)
|
|
384
|
+
or math.isfinite(resolved_nodata)):
|
|
385
|
+
raise ValueError(
|
|
386
|
+
f"nodata={nodata} must be finite or NaN"
|
|
387
|
+
)
|
|
388
|
+
dtype_str = "float32"
|
|
389
|
+
|
|
390
|
+
N = oversampling
|
|
391
|
+
over_transform = from_origin(minx, maxy, scale / N, scale / N)
|
|
392
|
+
over_shape = (H * N, W * N)
|
|
393
|
+
inv_n2 = 1.0 / (N * N)
|
|
394
|
+
|
|
395
|
+
arr = np.zeros((n, H, W), dtype=np.float32)
|
|
396
|
+
for i, code in enumerate(codes, start=1):
|
|
397
|
+
subset = gdf.loc[gdf[ecosystem_col] == code, "geometry"]
|
|
398
|
+
if subset.empty:
|
|
399
|
+
continue
|
|
400
|
+
mask = _rio_rasterize(
|
|
401
|
+
shapes=(
|
|
402
|
+
(geom, 1) for geom in subset if not geom.is_empty
|
|
403
|
+
),
|
|
404
|
+
out_shape=over_shape,
|
|
405
|
+
transform=over_transform,
|
|
406
|
+
fill=0,
|
|
407
|
+
dtype="uint8",
|
|
408
|
+
all_touched=False,
|
|
409
|
+
)
|
|
410
|
+
cov = mask.reshape(H, N, W, N).sum(axis=(1, 3))
|
|
411
|
+
arr[i - 1, :, :] = cov.astype(np.float32) * inv_n2
|
|
412
|
+
count = n
|
|
413
|
+
|
|
414
|
+
profile = {
|
|
415
|
+
"driver": "COG",
|
|
416
|
+
"dtype": dtype_str,
|
|
417
|
+
"count": count,
|
|
418
|
+
"height": H,
|
|
419
|
+
"width": W,
|
|
420
|
+
"crs": crs,
|
|
421
|
+
"transform": transform,
|
|
422
|
+
"nodata": resolved_nodata,
|
|
423
|
+
"compress": "deflate",
|
|
424
|
+
"predictor": 2 if mode == "index" else 3,
|
|
425
|
+
"blocksize": 512,
|
|
426
|
+
"overview_resampling": "nearest" if mode == "index" else "average",
|
|
427
|
+
"BIGTIFF": "IF_SAFER",
|
|
428
|
+
}
|
|
429
|
+
with rasterio.open(path, "w", **profile) as dst:
|
|
430
|
+
if mode == "index":
|
|
431
|
+
dst.write(arr, 1)
|
|
432
|
+
else:
|
|
433
|
+
dst.write(arr)
|
|
434
|
+
for i, code in mapping.items():
|
|
435
|
+
dst.set_band_description(i, code)
|
|
436
|
+
dst.update_tags(
|
|
437
|
+
ECOSYSTEM_COLUMN=ecosystem_col,
|
|
438
|
+
ECOSYSTEM_INDEX_JSON=json.dumps(mapping),
|
|
439
|
+
RASTERIZE_MODE=mode,
|
|
440
|
+
)
|
|
441
|
+
dst.update_tags(1, **{f"ECO_{i}": c for i, c in mapping.items()})
|
|
442
|
+
|
|
443
|
+
return mapping
|
|
444
|
+
|
|
445
|
+
def _feature_count(self) -> int | None:
|
|
446
|
+
"""Return the number of features, or None if not applicable."""
|
|
447
|
+
if hasattr(self._cached, '__len__'):
|
|
448
|
+
return len(self._cached)
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
# -- export / write -------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
def to_geodataframe(self) -> "gpd.GeoDataFrame":
|
|
454
|
+
"""Convert to a GeoDataFrame.
|
|
455
|
+
|
|
456
|
+
For vector local backends, returns the loaded GeoDataFrame directly.
|
|
457
|
+
"""
|
|
458
|
+
import geopandas as gpd # noqa: F401
|
|
459
|
+
|
|
460
|
+
if self.kind == EcosystemKind.VECTOR_LOCAL:
|
|
461
|
+
return self.load()
|
|
462
|
+
raise NotImplementedError(
|
|
463
|
+
f"to_geodataframe not supported for {self.kind.value}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def to_parquet(self, path) -> None:
|
|
467
|
+
"""Write ecosystem data as a GeoParquet file."""
|
|
468
|
+
_write_parquet(self.to_geodataframe(), path)
|
|
469
|
+
|
|
470
|
+
def to_geojson(self, path) -> None:
|
|
471
|
+
"""Write ecosystem data as a GeoJSON file."""
|
|
472
|
+
from pathlib import Path
|
|
473
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
474
|
+
gdf = self.to_geodataframe()
|
|
475
|
+
gdf.to_file(path, driver="GeoJSON")
|
|
476
|
+
|
|
477
|
+
def to_ee_feature_collection(self, asset_id: str, *,
|
|
478
|
+
gcs_bucket: str | None = None):
|
|
479
|
+
"""Upload ecosystem data as an Earth Engine asset.
|
|
480
|
+
|
|
481
|
+
Requires the optional ``rle-python-gee`` package. Small datasets are
|
|
482
|
+
uploaded inline; large datasets (> 1000 features) are written as a
|
|
483
|
+
shapefile to GCS and ingested (requires ``gcs_bucket``).
|
|
484
|
+
|
|
485
|
+
Returns the task/result, or None if asset already exists.
|
|
486
|
+
"""
|
|
487
|
+
try:
|
|
488
|
+
from rle.gee.upload import upload_gdf_to_ee_asset
|
|
489
|
+
except ImportError:
|
|
490
|
+
raise ImportError(
|
|
491
|
+
"Earth Engine export requires the 'rle-python-gee' package. "
|
|
492
|
+
"Install it with: pip install rle-python-gee"
|
|
493
|
+
) from None
|
|
494
|
+
gdf = self.to_geodataframe()
|
|
495
|
+
return upload_gdf_to_ee_asset(
|
|
496
|
+
gdf, asset_id, gcs_bucket=gcs_bucket, description="ecosystem_export"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# -- visualization -------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
def to_layer(self, *, get_fill_color=None, get_line_color=None, max_features: int = 1000):
|
|
502
|
+
"""Return lonboard layer(s) for this ecosystem dataset.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
get_fill_color: Fill color for polygons.
|
|
506
|
+
get_line_color: Line color for polygons.
|
|
507
|
+
max_features: Maximum number of features to display. Default 1000.
|
|
508
|
+
"""
|
|
509
|
+
if self.kind != EcosystemKind.VECTOR_LOCAL:
|
|
510
|
+
raise NotImplementedError(
|
|
511
|
+
f"Visualization not yet supported for {self.kind.value}"
|
|
512
|
+
)
|
|
513
|
+
try:
|
|
514
|
+
from lonboard import PolygonLayer
|
|
515
|
+
except ImportError:
|
|
516
|
+
raise ImportError(
|
|
517
|
+
"lonboard is required for visualization. "
|
|
518
|
+
"Install it with: pip install rle-python[viz]"
|
|
519
|
+
) from None
|
|
520
|
+
|
|
521
|
+
if get_fill_color is None:
|
|
522
|
+
get_fill_color = [0, 255, 0, 128]
|
|
523
|
+
if get_line_color is None:
|
|
524
|
+
get_line_color = [0, 0, 0, 255]
|
|
525
|
+
|
|
526
|
+
gdf = self.load()
|
|
527
|
+
if gdf.empty:
|
|
528
|
+
return []
|
|
529
|
+
if len(gdf) > max_features:
|
|
530
|
+
raise ValueError(
|
|
531
|
+
f"Dataset has {len(gdf):,} features, exceeding max_features={max_features:,}. "
|
|
532
|
+
f"Use .limit() or .filter() to reduce, increase max_features, "
|
|
533
|
+
f"or upload to Earth Engine for tile-based visualization."
|
|
534
|
+
)
|
|
535
|
+
return [PolygonLayer.from_geopandas(
|
|
536
|
+
gdf,
|
|
537
|
+
get_fill_color=get_fill_color,
|
|
538
|
+
get_line_color=get_line_color,
|
|
539
|
+
line_width_min_pixels=1,
|
|
540
|
+
)]
|
|
541
|
+
|
|
542
|
+
def to_gdf_for_viz(self, *, get_fill_color=None, get_line_color=None, **_):
|
|
543
|
+
"""Return (gdf, style_dict) for static-image fallback rendering."""
|
|
544
|
+
if self.kind != EcosystemKind.VECTOR_LOCAL:
|
|
545
|
+
raise NotImplementedError(
|
|
546
|
+
f"Static rendering not supported for {self.kind.value}"
|
|
547
|
+
)
|
|
548
|
+
if get_fill_color is None:
|
|
549
|
+
get_fill_color = [0, 255, 0, 128]
|
|
550
|
+
if get_line_color is None:
|
|
551
|
+
get_line_color = [0, 0, 0, 255]
|
|
552
|
+
return self.load(), {"fill": get_fill_color, "edge": get_line_color}
|
|
553
|
+
|
|
554
|
+
def to_map(self, *, max_features: int = 1000, **kwargs):
|
|
555
|
+
"""Return a lonboard Map showing the ecosystem polygons.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
max_features: Maximum number of features to display. Default 1000.
|
|
559
|
+
**kwargs: Additional arguments passed to lonboard.Map.
|
|
560
|
+
"""
|
|
561
|
+
try:
|
|
562
|
+
from lonboard import Map
|
|
563
|
+
except ImportError:
|
|
564
|
+
raise ImportError(
|
|
565
|
+
"lonboard is required for visualization. "
|
|
566
|
+
"Install it with: pip install rle-python[viz]"
|
|
567
|
+
) from None
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
layers = self.to_layer(max_features=max_features)
|
|
571
|
+
except ValueError as e:
|
|
572
|
+
from IPython.display import HTML, display
|
|
573
|
+
display(HTML(f"<div style='padding:12px;background:#fff3cd;border:1px solid #ffc107;border-radius:4px'>"
|
|
574
|
+
f"<b>Cannot display map:</b> {e}</div>"))
|
|
575
|
+
return None
|
|
576
|
+
return Map(layers=layers, **kwargs)
|
|
577
|
+
|
|
578
|
+
# -- display -------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
def __repr__(self) -> str:
|
|
581
|
+
return f"{type(self).__name__}(data={self._data!r})"
|
|
582
|
+
|
|
583
|
+
def _repr_html_(self) -> str:
|
|
584
|
+
parts = [
|
|
585
|
+
f"<b>{type(self).__name__}</b>",
|
|
586
|
+
f"Kind: {self.kind.value}",
|
|
587
|
+
f"Source: {self._data!r}",
|
|
588
|
+
]
|
|
589
|
+
if self._cached is not None:
|
|
590
|
+
count = self._feature_count()
|
|
591
|
+
if count is not None:
|
|
592
|
+
parts.append(f"Features: {count:,}")
|
|
593
|
+
return "<br>".join(parts)
|
|
594
|
+
|
|
595
|
+
# -- factory classmethods -------------------------------------------------
|
|
596
|
+
|
|
597
|
+
@classmethod
|
|
598
|
+
def from_file(cls, path, *, ecosystem_column: str, **kwargs) -> "Ecosystems":
|
|
599
|
+
"""Create from a vector file (Shapefile, GeoJSON, GeoParquet, etc.)."""
|
|
600
|
+
if str(path).endswith(".parquet"):
|
|
601
|
+
return EcosystemsGeoParquet(path, ecosystem_column=ecosystem_column, **kwargs)
|
|
602
|
+
return EcosystemsFile(path, ecosystem_column=ecosystem_column, **kwargs)
|
|
603
|
+
|
|
604
|
+
@classmethod
|
|
605
|
+
def from_parquet(cls, path, *, ecosystem_column: str, **kwargs) -> "Ecosystems":
|
|
606
|
+
"""Create from a GeoParquet file."""
|
|
607
|
+
return EcosystemsGeoParquet(path, ecosystem_column=ecosystem_column, **kwargs)
|
|
608
|
+
|
|
609
|
+
@classmethod
|
|
610
|
+
def from_cog(cls, data, **kwargs) -> "Ecosystems":
|
|
611
|
+
"""Create from a Cloud Optimized GeoTIFF."""
|
|
612
|
+
return EcosystemsCOG(data, **kwargs)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# ---------------------------------------------------------------------------
|
|
616
|
+
# Vector local backends
|
|
617
|
+
# ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
class EcosystemsFile(Ecosystems):
|
|
621
|
+
"""Ecosystem polygons from a vector file (Shapefile, GeoJSON, etc.)."""
|
|
622
|
+
|
|
623
|
+
kind = EcosystemKind.VECTOR_LOCAL
|
|
624
|
+
|
|
625
|
+
def __init__(self, data, *, ecosystem_column: str, ecosystem_name_column: str | None = None,
|
|
626
|
+
functional_group_column: str | None = None):
|
|
627
|
+
super().__init__(data, ecosystem_column=ecosystem_column,
|
|
628
|
+
ecosystem_name_column=ecosystem_name_column,
|
|
629
|
+
functional_group_column=functional_group_column)
|
|
630
|
+
|
|
631
|
+
def _load(self):
|
|
632
|
+
import geopandas as gpd
|
|
633
|
+
|
|
634
|
+
return gpd.read_file(self._data)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
class EcosystemsGeoParquet(Ecosystems):
|
|
638
|
+
"""Ecosystem polygons from a GeoParquet file (local, gs://, s3://, …)."""
|
|
639
|
+
|
|
640
|
+
kind = EcosystemKind.VECTOR_LOCAL
|
|
641
|
+
|
|
642
|
+
def __init__(self, data, *, ecosystem_column: str, ecosystem_name_column: str | None = None,
|
|
643
|
+
functional_group_column: str | None = None):
|
|
644
|
+
super().__init__(data, ecosystem_column=ecosystem_column,
|
|
645
|
+
ecosystem_name_column=ecosystem_name_column,
|
|
646
|
+
functional_group_column=functional_group_column)
|
|
647
|
+
|
|
648
|
+
def _load(self):
|
|
649
|
+
import geopandas as gpd
|
|
650
|
+
|
|
651
|
+
if isinstance(self._data, str) and self._data.startswith(
|
|
652
|
+
("http://", "https://", "gs://", "s3://", "az://")
|
|
653
|
+
):
|
|
654
|
+
import fsspec
|
|
655
|
+
|
|
656
|
+
with fsspec.open(self._data, "rb") as f:
|
|
657
|
+
return gpd.read_parquet(f)
|
|
658
|
+
return gpd.read_parquet(self._data)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
class EcosystemsGeoDataFrame(Ecosystems):
|
|
662
|
+
"""Ecosystem polygons from an in-memory GeoDataFrame."""
|
|
663
|
+
|
|
664
|
+
kind = EcosystemKind.VECTOR_LOCAL
|
|
665
|
+
|
|
666
|
+
def __init__(self, data, *, ecosystem_column: str, ecosystem_name_column: str | None = None,
|
|
667
|
+
functional_group_column: str | None = None):
|
|
668
|
+
super().__init__(data, ecosystem_column=ecosystem_column,
|
|
669
|
+
ecosystem_name_column=ecosystem_name_column,
|
|
670
|
+
functional_group_column=functional_group_column)
|
|
671
|
+
self._cached = data
|
|
672
|
+
|
|
673
|
+
def _load(self):
|
|
674
|
+
return self._data
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# ---------------------------------------------------------------------------
|
|
678
|
+
# Raster local backend
|
|
679
|
+
# ---------------------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
class EcosystemsCOG(Ecosystems):
|
|
683
|
+
"""Ecosystem coverage from a Cloud Optimized GeoTIFF."""
|
|
684
|
+
|
|
685
|
+
kind = EcosystemKind.RASTER_LOCAL
|
|
686
|
+
|
|
687
|
+
def _load(self):
|
|
688
|
+
import rioxarray # noqa: F401
|
|
689
|
+
|
|
690
|
+
return rioxarray.open_rasterio(self._data)
|