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