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/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
+ )