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_grid.py ADDED
@@ -0,0 +1,54 @@
1
+ """Generate AOO grid cells for local (non-Earth Engine) backends."""
2
+
3
+ import geopandas as gpd
4
+ import numpy as np
5
+ from pyproj import Transformer
6
+ from shapely.geometry import box
7
+
8
+
9
+ # ESRI:54034 World Cylindrical Equal Area — the IUCN-approved equal-area
10
+ # projection for RLE AOO grids. The Earth Engine backend (rle.gee.ee_rle.
11
+ # get_aoo_grid_projection) builds the equivalent ee.Projection from WKT.
12
+ AOO_CRS = "ESRI:54034"
13
+ AOO_CELL_SIZE = 10_000 # 10 km in meters
14
+
15
+
16
+ def generate_aoo_grid(bounds_4326: tuple[float, float, float, float]) -> gpd.GeoDataFrame:
17
+ """Generate a GeoDataFrame of AOO grid cells covering the given bounds.
18
+
19
+ Args:
20
+ bounds_4326: (minx, miny, maxx, maxy) in EPSG:4326.
21
+
22
+ Returns:
23
+ GeoDataFrame with grid cell polygons in EPSG:4326.
24
+ """
25
+ minx, miny, maxx, maxy = bounds_4326
26
+
27
+ # Transform bounds to equal-area projection
28
+ transformer = Transformer.from_crs("EPSG:4326", AOO_CRS, always_xy=True)
29
+ ea_minx, ea_miny = transformer.transform(minx, miny)
30
+ ea_maxx, ea_maxy = transformer.transform(maxx, maxy)
31
+
32
+ # Snap to grid origin (0, 0) so cells are globally consistent
33
+ ea_minx = np.floor(ea_minx / AOO_CELL_SIZE) * AOO_CELL_SIZE
34
+ ea_miny = np.floor(ea_miny / AOO_CELL_SIZE) * AOO_CELL_SIZE
35
+ ea_maxx = np.ceil(ea_maxx / AOO_CELL_SIZE) * AOO_CELL_SIZE
36
+ ea_maxy = np.ceil(ea_maxy / AOO_CELL_SIZE) * AOO_CELL_SIZE
37
+
38
+ xs = np.arange(ea_minx, ea_maxx, AOO_CELL_SIZE)
39
+ ys = np.arange(ea_miny, ea_maxy, AOO_CELL_SIZE)
40
+
41
+ grid_cols = []
42
+ grid_rows = []
43
+ cells = []
44
+ for x in xs:
45
+ for y in ys:
46
+ grid_cols.append(int(x / AOO_CELL_SIZE))
47
+ grid_rows.append(int(y / AOO_CELL_SIZE))
48
+ cells.append(box(x, y, x + AOO_CELL_SIZE, y + AOO_CELL_SIZE))
49
+
50
+ grid = gpd.GeoDataFrame(
51
+ {"grid_col": grid_cols, "grid_row": grid_rows, "geometry": cells},
52
+ crs=AOO_CRS,
53
+ )
54
+ return grid.to_crs("EPSG:4326")
rle/core/cli.py ADDED
@@ -0,0 +1,49 @@
1
+ """Command-line interface for rle-python core."""
2
+
3
+ import typer
4
+ from typing import Annotated
5
+
6
+ from rle.core import __version__
7
+
8
+ app = typer.Typer(
9
+ name="rle",
10
+ help="IUCN Red List of Ecosystems tools (core)",
11
+ add_completion=False,
12
+ )
13
+
14
+
15
+ @app.command()
16
+ def backends():
17
+ """List installed data-access backends (core + any plugins)."""
18
+ from rle.core.registry import iter_backends
19
+
20
+ infos = iter_backends()
21
+ if not infos:
22
+ print("No backends registered.")
23
+ return
24
+ width = max(len(b.name) for b in infos)
25
+ for b in sorted(infos, key=lambda x: (x.capability, x.name)):
26
+ dist = f" [{b.distribution}]" if b.distribution else ""
27
+ print(f" {b.name:<{width}} {b.capability}{dist}")
28
+
29
+
30
+ @app.callback(invoke_without_command=True)
31
+ def main(
32
+ ctx: typer.Context,
33
+ version: Annotated[
34
+ bool,
35
+ typer.Option("--version", "-v", help="Show version and exit"),
36
+ ] = False,
37
+ ):
38
+ """Main entry point for the rle CLI."""
39
+ if version:
40
+ print(f"rle-python version {__version__}")
41
+ raise typer.Exit()
42
+
43
+ if ctx.invoked_subcommand is None:
44
+ print("Hello from rle-python!")
45
+ print("\nUse --help to see available commands")
46
+
47
+
48
+ if __name__ == "__main__":
49
+ app()
@@ -0,0 +1,108 @@
1
+ """Assign hierarchical ecosystem codes to a vector dataset.
2
+
3
+ For each functional group code, the distinct ecosystem names are sorted
4
+ alphabetically and assigned a counter starting at 1. The counter is
5
+ appended to the functional group code to form the ecosystem code. For
6
+ example, two ecosystems in functional group ``T1.1`` become ``T1.1.1``
7
+ and ``T1.1.2``.
8
+ """
9
+
10
+ import warnings
11
+
12
+ import geopandas as gpd
13
+ import pandas as pd
14
+
15
+ # Shapefile DBF character fields are limited to 10 characters; longer
16
+ # values are silently truncated by downstream tools that export to .shp,
17
+ # which can collapse distinct ecosystems into the same reported code.
18
+ SHAPEFILE_VALUE_LIMIT = 10
19
+
20
+
21
+ def assign_ecosystem_codes(
22
+ gdf: gpd.GeoDataFrame,
23
+ *,
24
+ fg_code_col: str,
25
+ eco_name_col: str,
26
+ eco_code_col: str,
27
+ ) -> gpd.GeoDataFrame:
28
+ """Return a copy of ``gdf`` with a new column of hierarchical ecosystem codes.
29
+
30
+ Within each functional group, distinct ecosystem names are sorted
31
+ alphabetically before numbering, so the result is deterministic
32
+ regardless of input row order.
33
+
34
+ Args:
35
+ gdf: Input GeoDataFrame.
36
+ fg_code_col: Existing column holding the functional group code
37
+ (e.g. values like ``"T1.1"``).
38
+ eco_name_col: Existing column holding the ecosystem name.
39
+ eco_code_col: Name of the new column to create and populate.
40
+
41
+ Returns:
42
+ A copy of ``gdf`` with ``eco_code_col`` added. Rows whose
43
+ functional group code or ecosystem name is null receive a null
44
+ ecosystem code.
45
+
46
+ Raises:
47
+ ValueError: If ``fg_code_col`` or ``eco_name_col`` is missing
48
+ from ``gdf``, or if ``eco_code_col`` already exists.
49
+
50
+ Warns:
51
+ UserWarning: If any generated ecosystem code exceeds
52
+ :data:`SHAPEFILE_VALUE_LIMIT` characters. Such codes will be
53
+ silently truncated if later written to a shapefile field,
54
+ which can collapse distinct ecosystems when reporting
55
+ statistics per ecosystem.
56
+ """
57
+ # --- Validate columns ---
58
+ for col in (fg_code_col, eco_name_col):
59
+ if col not in gdf.columns:
60
+ raise ValueError(
61
+ f"column {col!r} not found in input. "
62
+ f"Available columns: {list(gdf.columns)}"
63
+ )
64
+ if eco_code_col in gdf.columns:
65
+ raise ValueError(
66
+ f"column {eco_code_col!r} already exists in input. "
67
+ f"Refusing to overwrite."
68
+ )
69
+
70
+ # --- Build (fg, eco_name) -> code mapping ---
71
+ # Distinct (fg, eco_name) pairs, skipping any with nulls, sorted so
72
+ # that within each functional group, ecosystem names are alphabetical.
73
+ pairs = (
74
+ gdf[[fg_code_col, eco_name_col]]
75
+ .dropna()
76
+ .drop_duplicates()
77
+ .sort_values([fg_code_col, eco_name_col])
78
+ )
79
+
80
+ code_map: dict[tuple, str] = {}
81
+ for fg, group in pairs.groupby(fg_code_col, sort=True):
82
+ for counter, eco_name in enumerate(group[eco_name_col].tolist(), start=1):
83
+ code_map[(fg, eco_name)] = f"{fg}.{counter}"
84
+
85
+ # --- Apply mapping to every row ---
86
+ def lookup(row):
87
+ fg = row[fg_code_col]
88
+ eco = row[eco_name_col]
89
+ if pd.isna(fg) or pd.isna(eco):
90
+ return None
91
+ return code_map.get((fg, eco))
92
+
93
+ result = gdf.copy()
94
+ result[eco_code_col] = result.apply(lookup, axis=1)
95
+
96
+ # --- Warn about values that exceed the shapefile character limit ---
97
+ long_codes = sorted(
98
+ {c for c in code_map.values() if len(c) > SHAPEFILE_VALUE_LIMIT}
99
+ )
100
+ if long_codes:
101
+ warnings.warn(
102
+ f"{len(long_codes)} ecosystem code(s) exceed "
103
+ f"{SHAPEFILE_VALUE_LIMIT} characters and will be truncated if "
104
+ f"later written to a shapefile field: {long_codes}",
105
+ stacklevel=2,
106
+ )
107
+
108
+ return result