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/aoo.py
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
"""Area of Occupancy (AOO) grid computation for RLE assessments.
|
|
2
|
+
|
|
3
|
+
Provides the ``AOOGrid`` / ``AOOGridPolygons`` class hierarchies and the
|
|
4
|
+
``make_aoo_grid()`` / ``make_aoo_polygons()`` helpers for computing AOO grids
|
|
5
|
+
from local data sources (GeoParquet, GeoJSON, COGs).
|
|
6
|
+
|
|
7
|
+
Earth Engine backends live in the optional ``rle-python-gee`` package
|
|
8
|
+
(``rle.gee``); construct them explicitly from there.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
|
|
15
|
+
import geopandas as gpd
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def slugify_ecosystem_name(name: str) -> str:
|
|
21
|
+
"""Sanitize an ecosystem name for use as a DataFrame column name.
|
|
22
|
+
|
|
23
|
+
Replaces any character that is not alphanumeric, underscore, or hyphen
|
|
24
|
+
with an underscore.
|
|
25
|
+
"""
|
|
26
|
+
return re.sub(r'[^a-zA-Z0-9_-]', '_', str(name))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
from rle.core.ecosystems import (
|
|
30
|
+
Ecosystems,
|
|
31
|
+
EcosystemKind,
|
|
32
|
+
EcosystemsFile,
|
|
33
|
+
EcosystemsGeoParquet,
|
|
34
|
+
EcosystemsCOG,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# AOO grid cell size in meters (10 x 10 km)
|
|
39
|
+
AOO_CELL_SIZE_M = 10_000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _remote_file_exists(path: str) -> bool:
|
|
43
|
+
"""Check if a file exists, supporting gs:// URIs and local paths."""
|
|
44
|
+
import fsspec
|
|
45
|
+
try:
|
|
46
|
+
fs, fpath = fsspec.core.url_to_fs(path)
|
|
47
|
+
return fs.exists(fpath)
|
|
48
|
+
except Exception:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AOOGridNotComputedError(Exception):
|
|
53
|
+
"""Raised when accessing grid data before compute() has been called."""
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
super().__init__(
|
|
57
|
+
"AOO grid has not been computed yet. "
|
|
58
|
+
"Call .compute() to run the computation."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AOOGrid(ABC):
|
|
63
|
+
"""Base class for Area of Occupancy grid computations.
|
|
64
|
+
|
|
65
|
+
Provides derived properties (cell count, AOO) and visualization methods.
|
|
66
|
+
|
|
67
|
+
Subclasses implement ``_compute()`` to run the computation and store
|
|
68
|
+
results in the appropriate backend, and ``_load_grid_cells()`` to
|
|
69
|
+
download results on demand.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, ecosystems: Ecosystems):
|
|
73
|
+
self._ecosystems = ecosystems
|
|
74
|
+
self._computed = False
|
|
75
|
+
self.task = None
|
|
76
|
+
self._grid_cells: gpd.GeoDataFrame | None = None
|
|
77
|
+
|
|
78
|
+
# -- classmethods ---------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_parquet(cls, data, *, ecosystem_column: str, **kwargs) -> "AOOGrid":
|
|
82
|
+
"""Create an AOO grid from a GeoParquet file."""
|
|
83
|
+
return AOOGridVectorLocal(
|
|
84
|
+
EcosystemsGeoParquet(data, ecosystem_column=ecosystem_column), **kwargs
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_file(cls, data, *, ecosystem_column: str, **kwargs) -> "AOOGrid":
|
|
89
|
+
"""Create an AOO grid from a vector file (Shapefile, GeoJSON, etc.)."""
|
|
90
|
+
return AOOGridVectorLocal(
|
|
91
|
+
EcosystemsFile(data, ecosystem_column=ecosystem_column), **kwargs
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_cog(cls, data, **kwargs) -> "AOOGrid":
|
|
96
|
+
"""Create an AOO grid from a Cloud Optimized GeoTIFF."""
|
|
97
|
+
return AOOGridCOG(EcosystemsCOG(data), **kwargs)
|
|
98
|
+
|
|
99
|
+
# -- computation ---------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def _compute(self) -> None:
|
|
103
|
+
"""Run the AOO grid computation and store results in the backend.
|
|
104
|
+
|
|
105
|
+
Must not return the result — store it in the backend (EE asset,
|
|
106
|
+
file, or in-memory cache for local backends).
|
|
107
|
+
"""
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def _load_grid_cells(self) -> gpd.GeoDataFrame:
|
|
112
|
+
"""Load grid cells from the backend.
|
|
113
|
+
|
|
114
|
+
Returns a GeoDataFrame with geometries in EPSG:4326.
|
|
115
|
+
"""
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
def compute(self) -> "AOOGrid":
|
|
119
|
+
"""Explicitly run the AOO grid computation.
|
|
120
|
+
|
|
121
|
+
Results are stored in the backend. Access them via ``grid_cells``.
|
|
122
|
+
Returns self for method chaining.
|
|
123
|
+
"""
|
|
124
|
+
self._compute()
|
|
125
|
+
self._computed = True
|
|
126
|
+
self._grid_cells = None # clear any stale local cache
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def grid_cells(self) -> gpd.GeoDataFrame:
|
|
131
|
+
"""GeoDataFrame of AOO grid cells. Raises if compute() not called."""
|
|
132
|
+
if not self._computed:
|
|
133
|
+
raise AOOGridNotComputedError()
|
|
134
|
+
if self._grid_cells is None:
|
|
135
|
+
self._grid_cells = self._load_grid_cells().reset_index(drop=True)
|
|
136
|
+
return self._grid_cells
|
|
137
|
+
|
|
138
|
+
# -- derived properties --------------------------------------------------
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def cell_count(self) -> int:
|
|
142
|
+
"""Total number of grid cells that intersect the ecosystem."""
|
|
143
|
+
return len(self.grid_cells)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def aoo_km2(self) -> float:
|
|
147
|
+
"""AOO in km² (cell count × 100 km² per cell)."""
|
|
148
|
+
return self.cell_count * (AOO_CELL_SIZE_M / 1000) ** 2
|
|
149
|
+
|
|
150
|
+
# -- export / write -------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def to_ee_feature_collection(self, asset_id: str, *,
|
|
153
|
+
gcs_bucket: str | None = None):
|
|
154
|
+
"""Export grid cells to an Earth Engine table asset.
|
|
155
|
+
|
|
156
|
+
Requires the optional ``rle-python-gee`` package.
|
|
157
|
+
Returns the task/result, or None if asset already exists.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
from rle.gee.upload import upload_gdf_to_ee_asset
|
|
161
|
+
except ImportError:
|
|
162
|
+
raise ImportError(
|
|
163
|
+
"Earth Engine export requires the 'rle-python-gee' package. "
|
|
164
|
+
"Install it with: pip install rle-python-gee"
|
|
165
|
+
) from None
|
|
166
|
+
|
|
167
|
+
return upload_gdf_to_ee_asset(
|
|
168
|
+
self.grid_cells, asset_id,
|
|
169
|
+
gcs_bucket=gcs_bucket, description="aoo_grid_export"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def to_parquet(self, path) -> None:
|
|
173
|
+
"""Write grid cells as a GeoParquet file."""
|
|
174
|
+
from rle.core.ecosystems import _write_parquet
|
|
175
|
+
_write_parquet(self.grid_cells, path)
|
|
176
|
+
|
|
177
|
+
# -- filtering -----------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def filter_by_ecosystem(self, ecosystem_name: str,
|
|
180
|
+
threshold: float = 0.0) -> "FilteredAOOGrid":
|
|
181
|
+
"""Return a filtered AOOGrid with only cells where the ecosystem
|
|
182
|
+
fraction exceeds *threshold*.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
ecosystem_name: Ecosystem name as it appears in the source data.
|
|
186
|
+
threshold: Minimum fractional area (0.0–1.0). Default 0.0 means
|
|
187
|
+
any presence.
|
|
188
|
+
"""
|
|
189
|
+
col = slugify_ecosystem_name(ecosystem_name)
|
|
190
|
+
if col not in self.grid_cells.columns:
|
|
191
|
+
skip = {"grid_col", "grid_row", "count_geoms", "count_ecosystems", "geometry"}
|
|
192
|
+
available = [c for c in self.grid_cells.columns if c not in skip]
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"Ecosystem column '{col}' not found. "
|
|
195
|
+
f"Available: {available}"
|
|
196
|
+
)
|
|
197
|
+
mask = self.grid_cells[col] > threshold
|
|
198
|
+
return FilteredAOOGrid(self, mask)
|
|
199
|
+
|
|
200
|
+
# -- visualization -------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def to_layer(self, *, get_fill_color=None, get_line_color=None):
|
|
203
|
+
"""Return a lonboard PolygonLayer of AOO grid cells."""
|
|
204
|
+
try:
|
|
205
|
+
from lonboard import PolygonLayer
|
|
206
|
+
except ImportError:
|
|
207
|
+
raise ImportError(
|
|
208
|
+
"lonboard is required for visualization. "
|
|
209
|
+
"Install it with: pip install rle-python[viz]"
|
|
210
|
+
) from None
|
|
211
|
+
|
|
212
|
+
if get_fill_color is None:
|
|
213
|
+
get_fill_color = [128, 128, 128, 128]
|
|
214
|
+
if get_line_color is None:
|
|
215
|
+
get_line_color = [0, 0, 0, 255]
|
|
216
|
+
|
|
217
|
+
if self.grid_cells.empty:
|
|
218
|
+
return []
|
|
219
|
+
return [PolygonLayer.from_geopandas(
|
|
220
|
+
self.grid_cells,
|
|
221
|
+
get_fill_color=get_fill_color,
|
|
222
|
+
get_line_color=get_line_color,
|
|
223
|
+
line_width_min_pixels=1,
|
|
224
|
+
)]
|
|
225
|
+
|
|
226
|
+
def to_gdf_for_viz(self, *, get_fill_color=None, get_line_color=None, **_):
|
|
227
|
+
"""Return (gdf, style_dict) for static-image fallback rendering."""
|
|
228
|
+
if get_fill_color is None:
|
|
229
|
+
get_fill_color = [128, 128, 128, 128]
|
|
230
|
+
if get_line_color is None:
|
|
231
|
+
get_line_color = [0, 0, 0, 255]
|
|
232
|
+
return self.grid_cells, {"fill": get_fill_color, "edge": get_line_color}
|
|
233
|
+
|
|
234
|
+
def to_map(self, *, get_fill_color=None, get_line_color=None, **kwargs):
|
|
235
|
+
"""Return a lonboard Map showing the AOO grid cells."""
|
|
236
|
+
try:
|
|
237
|
+
from lonboard import Map
|
|
238
|
+
except ImportError:
|
|
239
|
+
raise ImportError(
|
|
240
|
+
"lonboard is required for visualization. "
|
|
241
|
+
"Install it with: pip install rle-python[viz]"
|
|
242
|
+
) from None
|
|
243
|
+
|
|
244
|
+
layer_kwargs = {}
|
|
245
|
+
if get_fill_color is not None:
|
|
246
|
+
layer_kwargs["get_fill_color"] = get_fill_color
|
|
247
|
+
if get_line_color is not None:
|
|
248
|
+
layer_kwargs["get_line_color"] = get_line_color
|
|
249
|
+
layers = self.to_layer(**layer_kwargs)
|
|
250
|
+
return Map(layers=layers, **kwargs)
|
|
251
|
+
|
|
252
|
+
# -- display -------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def __repr__(self) -> str:
|
|
255
|
+
if not self._computed:
|
|
256
|
+
return f"{type(self).__name__}(not computed)"
|
|
257
|
+
try:
|
|
258
|
+
return (
|
|
259
|
+
f"{type(self).__name__}("
|
|
260
|
+
f"cell_count={self.cell_count}, "
|
|
261
|
+
f"aoo_km2={self.aoo_km2:.0f})"
|
|
262
|
+
)
|
|
263
|
+
except RuntimeError:
|
|
264
|
+
return f"{type(self).__name__}(computed, results pending)"
|
|
265
|
+
|
|
266
|
+
def _repr_html_(self) -> str:
|
|
267
|
+
if not self._computed:
|
|
268
|
+
return (
|
|
269
|
+
f"<b>{type(self).__name__}</b><br>"
|
|
270
|
+
f"<i>Not computed — call .compute() to run</i>"
|
|
271
|
+
)
|
|
272
|
+
try:
|
|
273
|
+
return (
|
|
274
|
+
f"<b>{type(self).__name__}</b><br>"
|
|
275
|
+
f"Grid cells: {self.cell_count}<br>"
|
|
276
|
+
f"AOO: {self.aoo_km2:,.0f} km²"
|
|
277
|
+
)
|
|
278
|
+
except RuntimeError:
|
|
279
|
+
return (
|
|
280
|
+
f"<b>{type(self).__name__}</b><br>"
|
|
281
|
+
f"<i>Export task running — check status at "
|
|
282
|
+
f"<a href='https://code.earthengine.google.com/tasks'>EE Tasks</a></i>"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Filtered view
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class FilteredAOOGrid(AOOGrid):
|
|
292
|
+
"""A filtered view of an AOOGrid, showing only cells matching a predicate."""
|
|
293
|
+
|
|
294
|
+
def __init__(self, source: AOOGrid, mask):
|
|
295
|
+
self._source = source
|
|
296
|
+
self._mask = mask
|
|
297
|
+
self._computed = source._computed
|
|
298
|
+
self._grid_cells = None
|
|
299
|
+
self._ecosystems = source._ecosystems
|
|
300
|
+
|
|
301
|
+
def _compute(self):
|
|
302
|
+
raise RuntimeError(
|
|
303
|
+
"Cannot compute a filtered grid — compute the source grid first."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def _load_grid_cells(self):
|
|
307
|
+
return self._source.grid_cells[self._mask].reset_index(drop=True)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# Local vector backend (GeoJSON, GeoParquet)
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class AOOGridVectorLocal(AOOGrid):
|
|
316
|
+
"""AOO grid from a local vector dataset (GeoJSON or GeoParquet)."""
|
|
317
|
+
|
|
318
|
+
def _compute(self) -> None:
|
|
319
|
+
import pandas as pd
|
|
320
|
+
from rle.core.aoo_grid import generate_aoo_grid, AOO_CRS, AOO_CELL_SIZE
|
|
321
|
+
|
|
322
|
+
eco = self._ecosystems.load().reset_index(drop=True)
|
|
323
|
+
if eco.crs is not None and not eco.crs.equals("EPSG:4326"):
|
|
324
|
+
eco = eco.to_crs("EPSG:4326")
|
|
325
|
+
grid = generate_aoo_grid(eco.total_bounds)
|
|
326
|
+
|
|
327
|
+
eco_col = self._ecosystems.ecosystem_column
|
|
328
|
+
|
|
329
|
+
# Spatial join to find (grid_cell, ecosystem) pairs
|
|
330
|
+
joined = gpd.sjoin(grid, eco, how="inner", predicate="intersects")
|
|
331
|
+
if joined.empty:
|
|
332
|
+
self._computed_gdf = gpd.GeoDataFrame(
|
|
333
|
+
columns=["grid_col", "grid_row", "count_geoms",
|
|
334
|
+
"count_ecosystems", "geometry"]
|
|
335
|
+
)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
# Compute intersection areas in equal-area CRS
|
|
339
|
+
grid_ea = grid.to_crs(AOO_CRS)
|
|
340
|
+
eco_ea = eco.to_crs(AOO_CRS)
|
|
341
|
+
cell_area = AOO_CELL_SIZE * AOO_CELL_SIZE
|
|
342
|
+
|
|
343
|
+
rows = []
|
|
344
|
+
for grid_idx, eco_idx in zip(joined.index, joined["index_right"]):
|
|
345
|
+
isect = grid_ea.geometry.iloc[grid_idx].intersection(
|
|
346
|
+
eco_ea.geometry.iloc[eco_idx]
|
|
347
|
+
)
|
|
348
|
+
if isect.is_empty:
|
|
349
|
+
continue
|
|
350
|
+
fraction = isect.area / cell_area
|
|
351
|
+
row = {"grid_idx": grid_idx, "eco_idx": eco_idx, "fraction": fraction}
|
|
352
|
+
if eco_col is not None:
|
|
353
|
+
row["ecosystem"] = eco.iloc[eco_idx][eco_col]
|
|
354
|
+
rows.append(row)
|
|
355
|
+
|
|
356
|
+
if not rows:
|
|
357
|
+
self._computed_gdf = gpd.GeoDataFrame(
|
|
358
|
+
columns=["grid_col", "grid_row", "count_geoms",
|
|
359
|
+
"count_ecosystems", "geometry"]
|
|
360
|
+
)
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
fractions = pd.DataFrame(rows)
|
|
364
|
+
|
|
365
|
+
# Summary counts per grid cell
|
|
366
|
+
summary = fractions.groupby("grid_idx").agg(
|
|
367
|
+
count_geoms=("eco_idx", "count"),
|
|
368
|
+
count_ecosystems=("ecosystem", "nunique") if eco_col else ("eco_idx", "count"),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Build result with grid geometry
|
|
372
|
+
result = grid.loc[summary.index].copy()
|
|
373
|
+
result["count_geoms"] = summary["count_geoms"].values
|
|
374
|
+
result["count_ecosystems"] = summary["count_ecosystems"].values
|
|
375
|
+
|
|
376
|
+
# Wide columns: fractional area per ecosystem
|
|
377
|
+
if eco_col is not None:
|
|
378
|
+
pivot = fractions.pivot_table(
|
|
379
|
+
index="grid_idx", columns="ecosystem",
|
|
380
|
+
values="fraction", aggfunc="sum", fill_value=0.0,
|
|
381
|
+
)
|
|
382
|
+
# Sanitize ecosystem names for use as column names
|
|
383
|
+
pivot.columns = [
|
|
384
|
+
slugify_ecosystem_name(c)
|
|
385
|
+
for c in pivot.columns
|
|
386
|
+
]
|
|
387
|
+
# Ensure all intersecting grid cells have all ecosystem columns (fill 0)
|
|
388
|
+
result = result.join(pivot)
|
|
389
|
+
result[pivot.columns] = result[pivot.columns].fillna(0.0)
|
|
390
|
+
|
|
391
|
+
self._computed_gdf = result.reset_index(drop=True)
|
|
392
|
+
|
|
393
|
+
def _load_grid_cells(self) -> gpd.GeoDataFrame:
|
|
394
|
+
return self._computed_gdf
|
|
395
|
+
|
|
396
|
+
def to_polygons(self, **kwargs) -> "AOOGridPolygonVectorLocal":
|
|
397
|
+
"""Create intersection polygons for this local vector grid."""
|
|
398
|
+
return AOOGridPolygonVectorLocal(self, **kwargs)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# Backward-compatibility aliases
|
|
402
|
+
AOOGridGeoParquet = AOOGridVectorLocal
|
|
403
|
+
AOOGridGeoJSON = AOOGridVectorLocal
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# COG (Cloud Optimized GeoTIFF) backend
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class AOOGridCOG(AOOGrid):
|
|
412
|
+
"""AOO grid from a Cloud Optimized GeoTIFF."""
|
|
413
|
+
|
|
414
|
+
def _compute(self) -> None:
|
|
415
|
+
from rasterstats import zonal_stats
|
|
416
|
+
|
|
417
|
+
from rle.core.aoo_grid import generate_aoo_grid
|
|
418
|
+
|
|
419
|
+
rds = self._ecosystems.load()
|
|
420
|
+
# Get bounds in geographic coords
|
|
421
|
+
bounds = rds.rio.transform_bounds("EPSG:4326")
|
|
422
|
+
grid = generate_aoo_grid(bounds)
|
|
423
|
+
|
|
424
|
+
# Reproject raster to equal-area for zonal stats
|
|
425
|
+
rds_ea = rds.rio.reproject("ESRI:54034")
|
|
426
|
+
grid_ea = grid.to_crs("ESRI:54034")
|
|
427
|
+
|
|
428
|
+
stats = zonal_stats(
|
|
429
|
+
grid_ea.geometry,
|
|
430
|
+
rds_ea.values[0],
|
|
431
|
+
affine=rds_ea.rio.transform(),
|
|
432
|
+
stats=["mean"],
|
|
433
|
+
nodata=rds_ea.rio.nodata,
|
|
434
|
+
)
|
|
435
|
+
# Keep only cells with non-zero values
|
|
436
|
+
has_data = [bool(s["mean"]) for s in stats]
|
|
437
|
+
result = grid_ea[has_data]
|
|
438
|
+
|
|
439
|
+
self._computed_gdf = result[["geometry"]].to_crs("EPSG:4326").reset_index(drop=True)
|
|
440
|
+
|
|
441
|
+
def _load_grid_cells(self) -> gpd.GeoDataFrame:
|
|
442
|
+
return self._computed_gdf
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
# Factory functions
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def make_aoo_grid(data, **kwargs) -> AOOGrid:
|
|
451
|
+
"""Create an AOO grid from an ``Ecosystems`` instance.
|
|
452
|
+
|
|
453
|
+
Call ``.compute()`` to run the computation before accessing results.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
data: An ``Ecosystems`` instance. Local vector / raster ecosystems
|
|
457
|
+
return :class:`AOOGridVectorLocal` / :class:`AOOGridCOG`.
|
|
458
|
+
Earth Engine ecosystems require the ``rle-python-gee`` package;
|
|
459
|
+
construct the EE AOO backend explicitly from ``rle.gee``.
|
|
460
|
+
**kwargs: Additional arguments passed to the backend constructor.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
An AOOGrid instance. Call .compute() to run the computation.
|
|
464
|
+
|
|
465
|
+
Example:
|
|
466
|
+
>>> from rle.core import Ecosystems
|
|
467
|
+
>>> eco = Ecosystems.from_file("data.geojson", ecosystem_column="ECO_NAME")
|
|
468
|
+
>>> aoo = make_aoo_grid(eco).compute()
|
|
469
|
+
>>> print(aoo.cell_count)
|
|
470
|
+
"""
|
|
471
|
+
if isinstance(data, Ecosystems):
|
|
472
|
+
kind = data.kind
|
|
473
|
+
if kind == EcosystemKind.VECTOR_LOCAL:
|
|
474
|
+
return AOOGridVectorLocal(data, **kwargs)
|
|
475
|
+
if kind == EcosystemKind.RASTER_LOCAL:
|
|
476
|
+
return AOOGridCOG(data, **kwargs)
|
|
477
|
+
if kind in (EcosystemKind.EE_IMAGE, EcosystemKind.EE_FEATURE_COLLECTION):
|
|
478
|
+
raise ValueError(
|
|
479
|
+
f"AOO grid for {kind.value} requires the 'rle-python-gee' package. "
|
|
480
|
+
f"Install it and construct the Earth Engine AOO backend from rle.gee."
|
|
481
|
+
)
|
|
482
|
+
raise ValueError(f"AOO grid not yet supported for {kind.value}")
|
|
483
|
+
|
|
484
|
+
raise TypeError(
|
|
485
|
+
"make_aoo_grid expects an Ecosystems instance. Construct one first, e.g. "
|
|
486
|
+
"Ecosystems.from_file(path, ecosystem_column=...) or a backend from rle.gee."
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def make_aoo_grid_cached(data, *, cache_path, **kwargs) -> AOOGrid:
|
|
491
|
+
"""Return an AOOGrid backed by a local GeoParquet cache at ``cache_path``.
|
|
492
|
+
|
|
493
|
+
On cache hit, the grid cells are loaded from the parquet file and
|
|
494
|
+
``compute()`` is skipped. On cache miss, the grid is computed normally
|
|
495
|
+
and the result is written to ``cache_path`` for next time.
|
|
496
|
+
|
|
497
|
+
The returned AOOGrid behaves identically to ``make_aoo_grid(...).compute()``
|
|
498
|
+
— ``grid_cells``, ``filter_by_ecosystem``, ``to_layer``, ``aoo_km2`` etc.
|
|
499
|
+
all work the same way.
|
|
500
|
+
|
|
501
|
+
Cache invalidation is the caller's responsibility: delete ``cache_path``
|
|
502
|
+
if the source data changes.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
data: Same as ``make_aoo_grid`` — an Ecosystems instance.
|
|
506
|
+
cache_path: Local path or ``gs://`` URI for the GeoParquet cache file.
|
|
507
|
+
Parent directories are created on write.
|
|
508
|
+
**kwargs: Forwarded to ``make_aoo_grid``.
|
|
509
|
+
"""
|
|
510
|
+
aoo = make_aoo_grid(data, **kwargs)
|
|
511
|
+
cache_path_str = str(cache_path)
|
|
512
|
+
if _remote_file_exists(cache_path_str):
|
|
513
|
+
aoo._grid_cells = gpd.read_parquet(cache_path_str)
|
|
514
|
+
aoo._computed = True
|
|
515
|
+
logger.info("Loaded AOO grid from cache: %s", cache_path_str)
|
|
516
|
+
return aoo
|
|
517
|
+
aoo.compute()
|
|
518
|
+
aoo.to_parquet(cache_path_str)
|
|
519
|
+
logger.info("Wrote AOO grid cache: %s", cache_path_str)
|
|
520
|
+
return aoo
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ---------------------------------------------------------------------------
|
|
524
|
+
# AOO Grid Polygons — intersection geometries
|
|
525
|
+
# ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class AOOGridPolygonsNotComputedError(Exception):
|
|
529
|
+
"""Raised when accessing polygon data before compute() has been called."""
|
|
530
|
+
|
|
531
|
+
def __init__(self):
|
|
532
|
+
super().__init__(
|
|
533
|
+
"AOO grid polygons have not been computed yet. "
|
|
534
|
+
"Call .compute() to run the computation."
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
class AOOGridPolygons(ABC):
|
|
539
|
+
"""Base class for AOO grid × ecosystem intersection polygons.
|
|
540
|
+
|
|
541
|
+
Each row represents the geometric intersection of one grid cell with
|
|
542
|
+
one ecosystem polygon. Subclasses implement ``_compute()`` to produce
|
|
543
|
+
the polygons and ``_load_polygons()`` to retrieve them.
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
def __init__(self, aoo_grid: AOOGrid):
|
|
547
|
+
self._aoo_grid = aoo_grid
|
|
548
|
+
self._computed = False
|
|
549
|
+
self._polygons: gpd.GeoDataFrame | None = None
|
|
550
|
+
self.task = None
|
|
551
|
+
|
|
552
|
+
# -- abstract interface --------------------------------------------------
|
|
553
|
+
|
|
554
|
+
@abstractmethod
|
|
555
|
+
def _compute(self) -> None:
|
|
556
|
+
"""Run the intersection computation and store results in backend."""
|
|
557
|
+
|
|
558
|
+
@abstractmethod
|
|
559
|
+
def _load_polygons(self) -> gpd.GeoDataFrame:
|
|
560
|
+
"""Load the computed intersection polygons as a GeoDataFrame."""
|
|
561
|
+
|
|
562
|
+
# -- public API ----------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
def compute(self) -> "AOOGridPolygons":
|
|
565
|
+
"""Run the intersection computation. Returns *self* for chaining."""
|
|
566
|
+
self._compute()
|
|
567
|
+
self._computed = True
|
|
568
|
+
self._polygons = None # clear cache
|
|
569
|
+
return self
|
|
570
|
+
|
|
571
|
+
@property
|
|
572
|
+
def polygons(self) -> gpd.GeoDataFrame:
|
|
573
|
+
"""The intersection polygons as a GeoDataFrame."""
|
|
574
|
+
if not self._computed:
|
|
575
|
+
raise AOOGridPolygonsNotComputedError()
|
|
576
|
+
if self._polygons is None:
|
|
577
|
+
self._polygons = self._load_polygons().reset_index(drop=True)
|
|
578
|
+
return self._polygons
|
|
579
|
+
|
|
580
|
+
@property
|
|
581
|
+
def polygon_count(self) -> int:
|
|
582
|
+
"""Number of (grid cell × ecosystem) intersection polygons."""
|
|
583
|
+
return len(self.polygons)
|
|
584
|
+
|
|
585
|
+
def filter_by_ecosystem(self, ecosystem_name: str) -> "FilteredAOOGridPolygons":
|
|
586
|
+
"""Return a filtered view with only polygons for the given ecosystem."""
|
|
587
|
+
eco_col = self._aoo_grid._ecosystems.ecosystem_column
|
|
588
|
+
if eco_col is None:
|
|
589
|
+
raise ValueError("ecosystem_column is not set on the source data")
|
|
590
|
+
mask = self.polygons[eco_col] == ecosystem_name
|
|
591
|
+
return FilteredAOOGridPolygons(self, mask)
|
|
592
|
+
|
|
593
|
+
# -- display -------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
def __repr__(self) -> str:
|
|
596
|
+
if not self._computed:
|
|
597
|
+
return f"{type(self).__name__}(not computed)"
|
|
598
|
+
try:
|
|
599
|
+
return f"{type(self).__name__}(polygons={self.polygon_count})"
|
|
600
|
+
except RuntimeError:
|
|
601
|
+
return f"{type(self).__name__}(computed, results pending)"
|
|
602
|
+
|
|
603
|
+
def _repr_html_(self) -> str:
|
|
604
|
+
if not self._computed:
|
|
605
|
+
return (
|
|
606
|
+
f"<b>{type(self).__name__}</b><br>"
|
|
607
|
+
f"<i>Not computed — call .compute() to run</i>"
|
|
608
|
+
)
|
|
609
|
+
try:
|
|
610
|
+
count = self.polygon_count
|
|
611
|
+
except Exception:
|
|
612
|
+
return (
|
|
613
|
+
f"<b>{type(self).__name__}</b><br>"
|
|
614
|
+
f"<i>Computed — polygons not yet available (export may be running)</i>"
|
|
615
|
+
)
|
|
616
|
+
return (
|
|
617
|
+
f"<b>{type(self).__name__}</b><br>"
|
|
618
|
+
f"Polygons: {count:,}"
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# -- export / write -------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
def to_ee_feature_collection(self, asset_id: str, *,
|
|
624
|
+
gcs_bucket: str | None = None):
|
|
625
|
+
"""Export intersection polygons to an Earth Engine table asset.
|
|
626
|
+
|
|
627
|
+
Requires the optional ``rle-python-gee`` package.
|
|
628
|
+
Returns the task/result, or None if asset already exists.
|
|
629
|
+
"""
|
|
630
|
+
try:
|
|
631
|
+
from rle.gee.upload import upload_gdf_to_ee_asset
|
|
632
|
+
except ImportError:
|
|
633
|
+
raise ImportError(
|
|
634
|
+
"Earth Engine export requires the 'rle-python-gee' package. "
|
|
635
|
+
"Install it with: pip install rle-python-gee"
|
|
636
|
+
) from None
|
|
637
|
+
|
|
638
|
+
return upload_gdf_to_ee_asset(
|
|
639
|
+
self.polygons, asset_id,
|
|
640
|
+
gcs_bucket=gcs_bucket, description="aoo_grid_polygons_export"
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
def to_parquet(self, path) -> None:
|
|
644
|
+
"""Write intersection polygons as a GeoParquet file."""
|
|
645
|
+
from rle.core.ecosystems import _write_parquet
|
|
646
|
+
_write_parquet(self.polygons, path)
|
|
647
|
+
|
|
648
|
+
# -- visualization -------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
def to_layer(self, *, get_fill_color=None, get_line_color=None):
|
|
651
|
+
"""Return lonboard layer(s) for the intersection polygons."""
|
|
652
|
+
if not self._computed:
|
|
653
|
+
raise AOOGridPolygonsNotComputedError()
|
|
654
|
+
try:
|
|
655
|
+
from lonboard import PolygonLayer
|
|
656
|
+
except ImportError:
|
|
657
|
+
raise ImportError(
|
|
658
|
+
"lonboard is required for visualization. "
|
|
659
|
+
"Install it with: pip install rle-python[viz]"
|
|
660
|
+
) from None
|
|
661
|
+
|
|
662
|
+
if get_fill_color is None:
|
|
663
|
+
get_fill_color = [0, 128, 255, 128]
|
|
664
|
+
if get_line_color is None:
|
|
665
|
+
get_line_color = [0, 0, 0, 255]
|
|
666
|
+
|
|
667
|
+
gdf = self.polygons
|
|
668
|
+
if gdf.empty:
|
|
669
|
+
return []
|
|
670
|
+
if len(gdf) > 1000:
|
|
671
|
+
raise ValueError(
|
|
672
|
+
f"Dataset has {len(gdf):,} polygons, which is too many to "
|
|
673
|
+
f"display interactively. Export to parquet with "
|
|
674
|
+
f".polygons.to_parquet(path) instead."
|
|
675
|
+
)
|
|
676
|
+
return [PolygonLayer.from_geopandas(
|
|
677
|
+
gdf,
|
|
678
|
+
get_fill_color=get_fill_color,
|
|
679
|
+
get_line_color=get_line_color,
|
|
680
|
+
line_width_min_pixels=1,
|
|
681
|
+
)]
|
|
682
|
+
|
|
683
|
+
def to_gdf_for_viz(self, *, get_fill_color=None, get_line_color=None, **_):
|
|
684
|
+
"""Return (gdf, style_dict) for static-image fallback rendering."""
|
|
685
|
+
if not self._computed:
|
|
686
|
+
raise AOOGridPolygonsNotComputedError()
|
|
687
|
+
if get_fill_color is None:
|
|
688
|
+
get_fill_color = [0, 128, 255, 128]
|
|
689
|
+
if get_line_color is None:
|
|
690
|
+
get_line_color = [0, 0, 0, 255]
|
|
691
|
+
return self.polygons, {"fill": get_fill_color, "edge": get_line_color}
|
|
692
|
+
|
|
693
|
+
def to_map(self, *, get_fill_color=None, get_line_color=None, **kwargs):
|
|
694
|
+
"""Return a lonboard Map of the intersection polygons."""
|
|
695
|
+
try:
|
|
696
|
+
from lonboard import Map
|
|
697
|
+
except ImportError:
|
|
698
|
+
raise ImportError(
|
|
699
|
+
"lonboard is required for visualization. "
|
|
700
|
+
"Install it with: pip install rle-python[viz]"
|
|
701
|
+
) from None
|
|
702
|
+
|
|
703
|
+
layer_kwargs = {}
|
|
704
|
+
if get_fill_color is not None:
|
|
705
|
+
layer_kwargs["get_fill_color"] = get_fill_color
|
|
706
|
+
if get_line_color is not None:
|
|
707
|
+
layer_kwargs["get_line_color"] = get_line_color
|
|
708
|
+
try:
|
|
709
|
+
layers = self.to_layer(**layer_kwargs)
|
|
710
|
+
except ValueError as e:
|
|
711
|
+
from IPython.display import HTML, display
|
|
712
|
+
display(HTML(f"<div style='padding:12px;background:#fff3cd;border:1px solid #ffc107;border-radius:4px'>"
|
|
713
|
+
f"<b>Cannot display map:</b> {e}</div>"))
|
|
714
|
+
return None
|
|
715
|
+
return Map(layers=layers, **kwargs)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
class FilteredAOOGridPolygons(AOOGridPolygons):
|
|
719
|
+
"""A filtered view of AOOGridPolygons, showing only polygons matching a predicate."""
|
|
720
|
+
|
|
721
|
+
def __init__(self, source: AOOGridPolygons, mask):
|
|
722
|
+
self._source = source
|
|
723
|
+
self._mask = mask
|
|
724
|
+
self._computed = source._computed
|
|
725
|
+
self._polygons = None
|
|
726
|
+
self._aoo_grid = source._aoo_grid
|
|
727
|
+
|
|
728
|
+
def _compute(self):
|
|
729
|
+
raise RuntimeError(
|
|
730
|
+
"Cannot compute a filtered polygon set — compute the source first."
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
def _load_polygons(self):
|
|
734
|
+
return self._source.polygons[self._mask].reset_index(drop=True)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
class AOOGridPolygonVectorLocal(AOOGridPolygons):
|
|
738
|
+
"""Intersection polygons computed locally via shapely.
|
|
739
|
+
|
|
740
|
+
Intersects each grid cell with ecosystem features one at a time
|
|
741
|
+
to keep memory usage bounded.
|
|
742
|
+
"""
|
|
743
|
+
|
|
744
|
+
def _compute(self) -> None:
|
|
745
|
+
import math
|
|
746
|
+
import pandas as pd
|
|
747
|
+
from shapely.geometry import box
|
|
748
|
+
from rle.core.aoo_grid import AOO_CRS, AOO_CELL_SIZE
|
|
749
|
+
|
|
750
|
+
eco = self._aoo_grid._ecosystems.load()
|
|
751
|
+
if eco.crs is None:
|
|
752
|
+
eco = eco.set_crs("EPSG:4326")
|
|
753
|
+
eco_cea = eco.to_crs(AOO_CRS)
|
|
754
|
+
|
|
755
|
+
bounds_cea = eco_cea.total_bounds
|
|
756
|
+
sindex = eco_cea.sindex
|
|
757
|
+
|
|
758
|
+
col_min = math.floor(bounds_cea[0] / AOO_CELL_SIZE)
|
|
759
|
+
col_max = math.ceil(bounds_cea[2] / AOO_CELL_SIZE)
|
|
760
|
+
row_min = math.floor(bounds_cea[1] / AOO_CELL_SIZE)
|
|
761
|
+
row_max = math.ceil(bounds_cea[3] / AOO_CELL_SIZE)
|
|
762
|
+
|
|
763
|
+
chunks = []
|
|
764
|
+
for col in range(col_min, col_max):
|
|
765
|
+
for row in range(row_min, row_max):
|
|
766
|
+
x0 = col * AOO_CELL_SIZE
|
|
767
|
+
y0 = row * AOO_CELL_SIZE
|
|
768
|
+
cell = box(x0, y0, x0 + AOO_CELL_SIZE, y0 + AOO_CELL_SIZE)
|
|
769
|
+
|
|
770
|
+
candidates = list(sindex.query(cell))
|
|
771
|
+
if not candidates:
|
|
772
|
+
continue
|
|
773
|
+
|
|
774
|
+
subset = eco_cea.iloc[candidates]
|
|
775
|
+
intersections = subset.intersection(cell)
|
|
776
|
+
mask = ~intersections.is_empty
|
|
777
|
+
if not mask.any():
|
|
778
|
+
continue
|
|
779
|
+
|
|
780
|
+
result = subset.loc[mask].copy()
|
|
781
|
+
result["geometry"] = intersections[mask]
|
|
782
|
+
result["grid_col"] = col
|
|
783
|
+
result["grid_row"] = row
|
|
784
|
+
chunks.append(result)
|
|
785
|
+
|
|
786
|
+
if chunks:
|
|
787
|
+
self._computed_gdf = gpd.GeoDataFrame(
|
|
788
|
+
pd.concat(chunks, ignore_index=True), crs=AOO_CRS
|
|
789
|
+
)
|
|
790
|
+
else:
|
|
791
|
+
self._computed_gdf = gpd.GeoDataFrame(
|
|
792
|
+
columns=["geometry", "grid_col", "grid_row"]
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
def _load_polygons(self) -> gpd.GeoDataFrame:
|
|
796
|
+
return self._computed_gdf
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def make_aoo_polygons(aoo_grid: AOOGrid, **kwargs) -> AOOGridPolygons:
|
|
800
|
+
"""Create AOO grid polygons from an AOOGrid instance.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
aoo_grid: A computed local AOOGrid instance. For Earth Engine grids,
|
|
804
|
+
use the ``rle.gee`` backend's ``.to_polygons()`` method.
|
|
805
|
+
**kwargs: Additional arguments passed to the backend constructor.
|
|
806
|
+
|
|
807
|
+
Returns:
|
|
808
|
+
An AOOGridPolygons instance. Call .compute() to run.
|
|
809
|
+
"""
|
|
810
|
+
if isinstance(aoo_grid, AOOGridVectorLocal):
|
|
811
|
+
return AOOGridPolygonVectorLocal(aoo_grid, **kwargs)
|
|
812
|
+
raise ValueError(
|
|
813
|
+
f"AOOGridPolygons not supported for {type(aoo_grid).__name__}. "
|
|
814
|
+
f"For Earth Engine grids, use the rle.gee backend's .to_polygons()."
|
|
815
|
+
)
|