gridforge-spatial 0.1.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.
- gridforge/__init__.py +10 -0
- gridforge/align/__init__.py +1 -0
- gridforge/align/points.py +172 -0
- gridforge/align/raster.py +129 -0
- gridforge/align/vector.py +204 -0
- gridforge/cli.py +345 -0
- gridforge/demo.py +173 -0
- gridforge/errors.py +13 -0
- gridforge/grid/__init__.py +6 -0
- gridforge/grid/build.py +57 -0
- gridforge/grid/spec.py +209 -0
- gridforge/io/__init__.py +1 -0
- gridforge/io/datasets.py +317 -0
- gridforge/io/grid.py +110 -0
- gridforge/join.py +75 -0
- gridforge/provenance.py +77 -0
- gridforge/validation/__init__.py +11 -0
- gridforge/validation/dataset.py +376 -0
- gridforge/validation/report.py +41 -0
- gridforge_spatial-0.1.0.dist-info/METADATA +220 -0
- gridforge_spatial-0.1.0.dist-info/RECORD +24 -0
- gridforge_spatial-0.1.0.dist-info/WHEEL +4 -0
- gridforge_spatial-0.1.0.dist-info/entry_points.txt +2 -0
- gridforge_spatial-0.1.0.dist-info/licenses/LICENSE +202 -0
gridforge/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Deterministic spatial grid alignment and dataset engineering."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("gridforge-spatial")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - editable/install builds provide metadata
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Spatial alignment adapters."""
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Deterministic point-to-grid aggregation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import geopandas as gpd
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from pyproj import CRS
|
|
13
|
+
from shapely.geometry import Point
|
|
14
|
+
|
|
15
|
+
from gridforge.errors import DatasetError
|
|
16
|
+
from gridforge.grid.build import get_grid_spec
|
|
17
|
+
|
|
18
|
+
PointAggregation = Literal["count", "sum", "mean", "min", "max"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _point_frame(
|
|
22
|
+
points: gpd.GeoDataFrame | pd.DataFrame,
|
|
23
|
+
*,
|
|
24
|
+
x_column: str,
|
|
25
|
+
y_column: str,
|
|
26
|
+
source_crs: str | CRS | None,
|
|
27
|
+
) -> gpd.GeoDataFrame:
|
|
28
|
+
if isinstance(points, gpd.GeoDataFrame):
|
|
29
|
+
result = points.copy()
|
|
30
|
+
parsed_source_crs = None
|
|
31
|
+
if source_crs is not None:
|
|
32
|
+
try:
|
|
33
|
+
parsed_source_crs = CRS.from_user_input(source_crs)
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
raise DatasetError(f"invalid explicit source CRS: {source_crs!r}") from exc
|
|
36
|
+
if result.crs is None:
|
|
37
|
+
if parsed_source_crs is None:
|
|
38
|
+
raise DatasetError("source CRS is unknown; provide --source-crs explicitly")
|
|
39
|
+
result = result.set_crs(parsed_source_crs)
|
|
40
|
+
elif parsed_source_crs is not None and not parsed_source_crs.equals(result.crs):
|
|
41
|
+
raise DatasetError("explicit source CRS conflicts with embedded CRS")
|
|
42
|
+
if result.geometry.isna().any() or result.geometry.is_empty.any():
|
|
43
|
+
raise DatasetError("point dataset contains null or empty geometries")
|
|
44
|
+
if not result.geometry.geom_type.eq("Point").all():
|
|
45
|
+
raise DatasetError("point alignment requires Point geometries")
|
|
46
|
+
coordinates = np.array([(geom.x, geom.y) for geom in result.geometry], dtype=float)
|
|
47
|
+
else:
|
|
48
|
+
missing = [name for name in (x_column, y_column) if name not in points.columns]
|
|
49
|
+
if missing:
|
|
50
|
+
raise DatasetError(f"point table is missing coordinate column(s): {', '.join(missing)}")
|
|
51
|
+
if source_crs is None:
|
|
52
|
+
raise DatasetError("source CRS is unknown; provide --source-crs explicitly")
|
|
53
|
+
try:
|
|
54
|
+
parsed_source_crs = CRS.from_user_input(source_crs)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
raise DatasetError(f"invalid explicit source CRS: {source_crs!r}") from exc
|
|
57
|
+
try:
|
|
58
|
+
x_values = pd.to_numeric(points[x_column], errors="raise").to_numpy(dtype=float)
|
|
59
|
+
y_values = pd.to_numeric(points[y_column], errors="raise").to_numpy(dtype=float)
|
|
60
|
+
except (TypeError, ValueError) as exc:
|
|
61
|
+
raise DatasetError("point coordinates must be numeric") from exc
|
|
62
|
+
if not np.isfinite(x_values).all() or not np.isfinite(y_values).all():
|
|
63
|
+
raise DatasetError("point coordinates must be finite and non-null")
|
|
64
|
+
result = gpd.GeoDataFrame(
|
|
65
|
+
points.copy(),
|
|
66
|
+
geometry=[Point(x, y) for x, y in zip(x_values, y_values, strict=True)],
|
|
67
|
+
crs=parsed_source_crs,
|
|
68
|
+
)
|
|
69
|
+
coordinates = np.column_stack((x_values, y_values))
|
|
70
|
+
if not np.isfinite(coordinates).all():
|
|
71
|
+
raise DatasetError("point coordinates must be finite and non-null")
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _aggregation_list(value: str | Sequence[str]) -> tuple[PointAggregation, ...]:
|
|
76
|
+
values = [value] if isinstance(value, str) else list(value)
|
|
77
|
+
allowed = {"count", "sum", "mean", "min", "max"}
|
|
78
|
+
if not values or any(item not in allowed for item in values):
|
|
79
|
+
raise DatasetError("point aggregations must be count, sum, mean, min, or max")
|
|
80
|
+
if len(set(values)) != len(values):
|
|
81
|
+
raise DatasetError("point aggregations cannot contain duplicates")
|
|
82
|
+
return tuple(values) # type: ignore[return-value]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _aggregate(values: pd.Series, operation: PointAggregation) -> int | float | None:
|
|
86
|
+
present = values.dropna()
|
|
87
|
+
if operation == "count":
|
|
88
|
+
return int(len(present))
|
|
89
|
+
if present.empty:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
numeric = pd.to_numeric(present, errors="raise").to_numpy(dtype=float)
|
|
93
|
+
except (TypeError, ValueError) as exc:
|
|
94
|
+
raise DatasetError(f"{operation} aggregation requires numeric values") from exc
|
|
95
|
+
if operation == "sum":
|
|
96
|
+
return math.fsum(sorted(numeric.tolist()))
|
|
97
|
+
if operation == "mean":
|
|
98
|
+
return math.fsum(sorted(numeric.tolist())) / len(numeric)
|
|
99
|
+
if operation == "min":
|
|
100
|
+
return float(numeric.min())
|
|
101
|
+
return float(numeric.max())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def align_points(
|
|
105
|
+
points: gpd.GeoDataFrame | pd.DataFrame,
|
|
106
|
+
grid: gpd.GeoDataFrame,
|
|
107
|
+
*,
|
|
108
|
+
aggregations: Mapping[str, str | Sequence[str]],
|
|
109
|
+
x_column: str = "x",
|
|
110
|
+
y_column: str = "y",
|
|
111
|
+
source_crs: str | CRS | None = None,
|
|
112
|
+
) -> gpd.GeoDataFrame:
|
|
113
|
+
"""Assign points to canonical cells and aggregate requested attributes.
|
|
114
|
+
|
|
115
|
+
The returned frame contains every grid cell. ``point_count`` counts valid
|
|
116
|
+
point records, while empty numeric aggregates and empty-cell values remain
|
|
117
|
+
null; an empty ``count`` result is zero.
|
|
118
|
+
"""
|
|
119
|
+
spec = get_grid_spec(grid)
|
|
120
|
+
if not aggregations:
|
|
121
|
+
raise DatasetError("at least one value aggregation is required")
|
|
122
|
+
points_frame = _point_frame(
|
|
123
|
+
points,
|
|
124
|
+
x_column=x_column,
|
|
125
|
+
y_column=y_column,
|
|
126
|
+
source_crs=source_crs,
|
|
127
|
+
)
|
|
128
|
+
for column in aggregations:
|
|
129
|
+
if column not in points_frame.columns:
|
|
130
|
+
raise DatasetError(f"point value column does not exist: {column}")
|
|
131
|
+
try:
|
|
132
|
+
points_in_grid = points_frame.to_crs(spec.crs)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
raise DatasetError(f"cannot reproject point dataset: {exc}") from exc
|
|
135
|
+
|
|
136
|
+
operations = {column: _aggregation_list(value) for column, value in aggregations.items()}
|
|
137
|
+
output_names = [
|
|
138
|
+
f"{column}_{operation}" for column, values in operations.items() for operation in values
|
|
139
|
+
]
|
|
140
|
+
if len(output_names) != len(set(output_names)):
|
|
141
|
+
raise DatasetError("aggregation output column names are not unique")
|
|
142
|
+
reserved = set(grid.columns) | {"point_count"}
|
|
143
|
+
collisions = sorted(reserved.intersection(output_names))
|
|
144
|
+
if collisions:
|
|
145
|
+
joined = ", ".join(collisions)
|
|
146
|
+
raise DatasetError(f"aggregation output conflicts with grid columns: {joined}")
|
|
147
|
+
|
|
148
|
+
rows_by_cell: dict[str, list[int]] = {str(value): [] for value in grid["grid_id"]}
|
|
149
|
+
for index, point in enumerate(points_in_grid.geometry):
|
|
150
|
+
row_column = spec.cell_index(point.x, point.y)
|
|
151
|
+
if row_column is None:
|
|
152
|
+
continue
|
|
153
|
+
row, column = row_column
|
|
154
|
+
key = f"{row}:{column}"
|
|
155
|
+
if key in rows_by_cell:
|
|
156
|
+
rows_by_cell[key].append(index)
|
|
157
|
+
|
|
158
|
+
result = grid.copy()
|
|
159
|
+
point_counts: list[int] = []
|
|
160
|
+
aggregate_values: dict[str, list[int | float | None]] = {name: [] for name in output_names}
|
|
161
|
+
for grid_id in result["grid_id"].astype(str):
|
|
162
|
+
point_indexes = rows_by_cell[grid_id]
|
|
163
|
+
point_counts.append(len(point_indexes))
|
|
164
|
+
for value_column, value_operations in operations.items():
|
|
165
|
+
values = points_in_grid.iloc[point_indexes][value_column]
|
|
166
|
+
for operation in value_operations:
|
|
167
|
+
name = f"{value_column}_{operation}"
|
|
168
|
+
aggregate_values[name].append(_aggregate(values, operation))
|
|
169
|
+
result["point_count"] = point_counts
|
|
170
|
+
for name, values in aggregate_values.items():
|
|
171
|
+
result[name] = values
|
|
172
|
+
return result
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Raster reprojection and canonical-cell aggregation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import geopandas as gpd
|
|
9
|
+
import numpy as np
|
|
10
|
+
import rasterio
|
|
11
|
+
from affine import Affine
|
|
12
|
+
from pyproj import CRS
|
|
13
|
+
from rasterio.enums import Resampling
|
|
14
|
+
from rasterio.vrt import WarpedVRT
|
|
15
|
+
from rasterio.warp import reproject
|
|
16
|
+
|
|
17
|
+
from gridforge.errors import DatasetError
|
|
18
|
+
from gridforge.grid.build import get_grid_spec
|
|
19
|
+
from gridforge.io.datasets import _crs_label, _resolved_crs
|
|
20
|
+
|
|
21
|
+
RasterAggregation = Literal["mean", "min", "max"]
|
|
22
|
+
_AGGREGATION_RESAMPLING = {
|
|
23
|
+
"mean": Resampling.average,
|
|
24
|
+
"min": Resampling.min,
|
|
25
|
+
"max": Resampling.max,
|
|
26
|
+
}
|
|
27
|
+
_REPROJECTION_RESAMPLING = {
|
|
28
|
+
"nearest": Resampling.nearest,
|
|
29
|
+
"bilinear": Resampling.bilinear,
|
|
30
|
+
"cubic": Resampling.cubic,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def align_raster(
|
|
35
|
+
path: str | Path,
|
|
36
|
+
grid: gpd.GeoDataFrame,
|
|
37
|
+
*,
|
|
38
|
+
aggregation: RasterAggregation,
|
|
39
|
+
band: int = 1,
|
|
40
|
+
source_crs: str | CRS | None = None,
|
|
41
|
+
resampling: str | None = None,
|
|
42
|
+
value_name: str | None = None,
|
|
43
|
+
) -> gpd.GeoDataFrame:
|
|
44
|
+
"""Warp a raster to the grid CRS, then aggregate valid pixels per cell.
|
|
45
|
+
|
|
46
|
+
Reprojection uses ``nearest`` by default, or the explicitly selected
|
|
47
|
+
``nearest``, ``bilinear``, or ``cubic`` method. Cell aggregation is a
|
|
48
|
+
separate, documented mapping: mean→average, min→min, max→max. Nodata and
|
|
49
|
+
cells outside the source footprint remain NaN.
|
|
50
|
+
"""
|
|
51
|
+
if aggregation not in _AGGREGATION_RESAMPLING:
|
|
52
|
+
raise DatasetError("aggregation must be mean, min, or max")
|
|
53
|
+
selected_resampling = resampling or "nearest"
|
|
54
|
+
if selected_resampling not in _REPROJECTION_RESAMPLING:
|
|
55
|
+
allowed = ", ".join(_REPROJECTION_RESAMPLING)
|
|
56
|
+
raise DatasetError(f"resampling must be one of: {allowed}")
|
|
57
|
+
if band < 1:
|
|
58
|
+
raise DatasetError("band index must be a positive one-based integer")
|
|
59
|
+
|
|
60
|
+
spec = get_grid_spec(grid)
|
|
61
|
+
input_path = Path(path)
|
|
62
|
+
output_name = value_name or input_path.stem
|
|
63
|
+
if not output_name or output_name in grid.columns:
|
|
64
|
+
raise DatasetError("raster value_name must be nonempty and cannot match a grid column")
|
|
65
|
+
output_column = f"{output_name}_{aggregation}"
|
|
66
|
+
if output_column in grid.columns:
|
|
67
|
+
raise DatasetError(f"raster output column conflicts with grid: {output_column}")
|
|
68
|
+
|
|
69
|
+
row_start, row_end, col_start, col_end = spec.index_extent
|
|
70
|
+
width = col_end - col_start + 1
|
|
71
|
+
height = row_end - row_start + 1
|
|
72
|
+
left = spec.origin.x + col_start * spec.cell_size
|
|
73
|
+
top = spec.origin.y - row_start * spec.cell_size
|
|
74
|
+
cell_size = float(spec.cell_size)
|
|
75
|
+
target_transform = Affine.translation(float(left), float(top)) @ Affine.scale(
|
|
76
|
+
cell_size, -cell_size
|
|
77
|
+
)
|
|
78
|
+
destination = np.full((height, width), np.nan, dtype="float64")
|
|
79
|
+
source_nodata: float | int | None = None
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
with rasterio.open(input_path) as source:
|
|
83
|
+
resolved_crs = _resolved_crs(source.crs, source_crs)
|
|
84
|
+
source_nodata = source.nodata
|
|
85
|
+
if band > source.count:
|
|
86
|
+
raise DatasetError(f"band index {band} exceeds raster band count {source.count}")
|
|
87
|
+
with WarpedVRT(
|
|
88
|
+
source,
|
|
89
|
+
src_crs=resolved_crs,
|
|
90
|
+
crs=spec.crs,
|
|
91
|
+
resampling=_REPROJECTION_RESAMPLING[selected_resampling],
|
|
92
|
+
nodata=np.nan,
|
|
93
|
+
dtype="float64",
|
|
94
|
+
init_dest_nodata=True,
|
|
95
|
+
) as warped:
|
|
96
|
+
reproject(
|
|
97
|
+
source=rasterio.band(warped, band),
|
|
98
|
+
destination=destination,
|
|
99
|
+
src_transform=warped.transform,
|
|
100
|
+
src_crs=warped.crs,
|
|
101
|
+
src_nodata=warped.nodata,
|
|
102
|
+
dst_transform=target_transform,
|
|
103
|
+
dst_crs=spec.crs,
|
|
104
|
+
dst_nodata=np.nan,
|
|
105
|
+
resampling=_AGGREGATION_RESAMPLING[aggregation],
|
|
106
|
+
init_dest_nodata=True,
|
|
107
|
+
)
|
|
108
|
+
except DatasetError:
|
|
109
|
+
raise
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
raise DatasetError(f"cannot align raster {input_path}: {exc}") from exc
|
|
112
|
+
|
|
113
|
+
result = grid.copy()
|
|
114
|
+
result[output_column] = destination.reshape(-1)
|
|
115
|
+
result.attrs["gridforge_operation"] = {
|
|
116
|
+
"operation": "raster_alignment",
|
|
117
|
+
"source": str(input_path),
|
|
118
|
+
"source_crs": _crs_label(resolved_crs),
|
|
119
|
+
"target_crs": spec.crs_id,
|
|
120
|
+
"grid_fingerprint": spec.fingerprint,
|
|
121
|
+
"grid_spec": spec.to_dict(),
|
|
122
|
+
"aggregation": aggregation,
|
|
123
|
+
"aggregation_resampling": _AGGREGATION_RESAMPLING[aggregation].name,
|
|
124
|
+
"resampling": selected_resampling,
|
|
125
|
+
"band": band,
|
|
126
|
+
"nodata": None if source_nodata is None else str(source_nodata),
|
|
127
|
+
"output_column": output_column,
|
|
128
|
+
}
|
|
129
|
+
return result
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Polygon-to-grid area coverage and attribute aggregation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import unicodedata
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
import geopandas as gpd
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from pyproj import CRS
|
|
14
|
+
from shapely.geometry import MultiPolygon, Polygon
|
|
15
|
+
from shapely.ops import unary_union
|
|
16
|
+
|
|
17
|
+
from gridforge.errors import DatasetError
|
|
18
|
+
from gridforge.grid.build import get_grid_spec
|
|
19
|
+
from gridforge.io.datasets import _resolved_crs
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _category_name(value: object) -> str:
|
|
23
|
+
return unicodedata.normalize("NFC", str(value)).strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _category_column(category_column: str, category: str) -> str:
|
|
27
|
+
encoded = quote(category, safe="") or "%00"
|
|
28
|
+
return f"{category_column}__{encoded}__coverage_ratio"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize_polygons(
|
|
32
|
+
polygons: gpd.GeoDataFrame,
|
|
33
|
+
*,
|
|
34
|
+
target_crs: CRS,
|
|
35
|
+
source_crs: str | CRS | None,
|
|
36
|
+
) -> gpd.GeoDataFrame:
|
|
37
|
+
if not isinstance(polygons, gpd.GeoDataFrame):
|
|
38
|
+
raise DatasetError("polygon alignment requires a GeoDataFrame")
|
|
39
|
+
crs = _resolved_crs(polygons.crs, source_crs)
|
|
40
|
+
result = polygons.set_crs(crs) if polygons.crs is None else polygons.copy()
|
|
41
|
+
if result.empty:
|
|
42
|
+
try:
|
|
43
|
+
return result.to_crs(target_crs)
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
raise DatasetError(f"cannot reproject polygon dataset: {exc}") from exc
|
|
46
|
+
if result.geometry.isna().any() or result.geometry.is_empty.any():
|
|
47
|
+
raise DatasetError("polygon dataset contains null or empty geometries")
|
|
48
|
+
supported = result.geometry.geom_type.isin(["Polygon", "MultiPolygon"])
|
|
49
|
+
if not supported.all():
|
|
50
|
+
raise DatasetError("polygon alignment requires Polygon or MultiPolygon geometries")
|
|
51
|
+
valid = result.geometry.is_valid
|
|
52
|
+
if not valid.all():
|
|
53
|
+
invalid_count = int((~valid).sum())
|
|
54
|
+
raise DatasetError(
|
|
55
|
+
f"polygon dataset contains {invalid_count} invalid polygon geometry row(s)"
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
return result.to_crs(target_crs)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
raise DatasetError(f"cannot reproject polygon dataset: {exc}") from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _numeric_values(polygons: gpd.GeoDataFrame, column: str) -> pd.Series:
|
|
64
|
+
try:
|
|
65
|
+
return pd.to_numeric(polygons[column], errors="raise")
|
|
66
|
+
except (TypeError, ValueError) as exc:
|
|
67
|
+
raise DatasetError(f"polygon numeric column must contain numeric values: {column}") from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def align_polygons(
|
|
71
|
+
polygons: gpd.GeoDataFrame,
|
|
72
|
+
grid: gpd.GeoDataFrame,
|
|
73
|
+
*,
|
|
74
|
+
category_column: str | None = None,
|
|
75
|
+
numeric_columns: Sequence[str] = (),
|
|
76
|
+
source_crs: str | CRS | None = None,
|
|
77
|
+
) -> gpd.GeoDataFrame:
|
|
78
|
+
"""Aggregate polygon intersections to a complete canonical grid.
|
|
79
|
+
|
|
80
|
+
Unique coverage is the area of the union of intersections divided by the
|
|
81
|
+
cell area. Category coverage uses a union independently per category;
|
|
82
|
+
overlapping categories may therefore have ratios whose sum exceeds one.
|
|
83
|
+
Numeric weighted means weight each feature value by its intersection area.
|
|
84
|
+
"""
|
|
85
|
+
spec = get_grid_spec(grid)
|
|
86
|
+
required = list(numeric_columns)
|
|
87
|
+
if category_column is not None:
|
|
88
|
+
required.append(category_column)
|
|
89
|
+
missing = sorted(set(required) - set(polygons.columns))
|
|
90
|
+
if missing:
|
|
91
|
+
raise DatasetError(f"polygon dataset is missing attribute column(s): {', '.join(missing)}")
|
|
92
|
+
if len(set(numeric_columns)) != len(numeric_columns):
|
|
93
|
+
raise DatasetError("numeric_columns cannot contain duplicates")
|
|
94
|
+
|
|
95
|
+
source = _normalize_polygons(
|
|
96
|
+
polygons,
|
|
97
|
+
target_crs=spec.crs,
|
|
98
|
+
source_crs=source_crs,
|
|
99
|
+
)
|
|
100
|
+
numeric = {column: _numeric_values(source, column) for column in numeric_columns}
|
|
101
|
+
|
|
102
|
+
categories: list[str] = []
|
|
103
|
+
category_by_row: dict[int, str] = {}
|
|
104
|
+
if category_column is not None:
|
|
105
|
+
for position, value in enumerate(source[category_column].tolist()):
|
|
106
|
+
if pd.isna(value):
|
|
107
|
+
continue
|
|
108
|
+
category = _category_name(value)
|
|
109
|
+
categories.append(category)
|
|
110
|
+
category_by_row[position] = category
|
|
111
|
+
category_values = sorted(set(categories))
|
|
112
|
+
category_outputs = {
|
|
113
|
+
category: _category_column(category_column or "category", category)
|
|
114
|
+
for category in category_values
|
|
115
|
+
}
|
|
116
|
+
if len(set(category_outputs.values())) != len(category_outputs):
|
|
117
|
+
raise DatasetError("normalized category names produce duplicate output columns")
|
|
118
|
+
|
|
119
|
+
output_names = ["polygon_count", "polygon_coverage_ratio"]
|
|
120
|
+
if category_column is not None:
|
|
121
|
+
output_names.append(f"dominant_{category_column}")
|
|
122
|
+
output_names.extend(category_outputs.values())
|
|
123
|
+
output_names.extend(f"{column}_weighted_mean" for column in numeric_columns)
|
|
124
|
+
if len(set(output_names)) != len(output_names):
|
|
125
|
+
raise DatasetError("polygon aggregation output column names are not unique")
|
|
126
|
+
collisions = sorted(set(output_names).intersection(grid.columns))
|
|
127
|
+
if collisions:
|
|
128
|
+
raise DatasetError(f"polygon output conflicts with grid columns: {', '.join(collisions)}")
|
|
129
|
+
|
|
130
|
+
spatial_index = source.sindex if not source.empty else None
|
|
131
|
+
result = grid.copy()
|
|
132
|
+
polygon_counts: list[int] = []
|
|
133
|
+
coverage_values: list[float] = []
|
|
134
|
+
dominant_values: list[str | None] = []
|
|
135
|
+
category_ratios: dict[str, list[float]] = {category: [] for category in category_values}
|
|
136
|
+
weighted_values: dict[str, list[float | None]] = {column: [] for column in numeric_columns}
|
|
137
|
+
|
|
138
|
+
for cell in grid.geometry:
|
|
139
|
+
if spatial_index is None:
|
|
140
|
+
candidate_indexes = []
|
|
141
|
+
else:
|
|
142
|
+
candidate_indexes = sorted(spatial_index.query(cell, predicate="intersects").tolist())
|
|
143
|
+
intersection_geometries = []
|
|
144
|
+
category_geometries: dict[str, list[Polygon | MultiPolygon]] = defaultdict(list)
|
|
145
|
+
weighted_pairs: dict[str, list[tuple[float, float]]] = defaultdict(list)
|
|
146
|
+
intersecting_features = 0
|
|
147
|
+
for position in candidate_indexes:
|
|
148
|
+
intersection = source.geometry.iloc[position].intersection(cell)
|
|
149
|
+
area = float(intersection.area)
|
|
150
|
+
if intersection.is_empty or area <= 0.0:
|
|
151
|
+
continue
|
|
152
|
+
intersecting_features += 1
|
|
153
|
+
intersection_geometries.append(intersection)
|
|
154
|
+
category = category_by_row.get(position)
|
|
155
|
+
if category is not None:
|
|
156
|
+
category_geometries[category].append(intersection)
|
|
157
|
+
for column in numeric_columns:
|
|
158
|
+
value = numeric[column].iloc[position]
|
|
159
|
+
if pd.notna(value):
|
|
160
|
+
weighted_pairs[column].append((float(value), area))
|
|
161
|
+
|
|
162
|
+
cell_area = float(cell.area)
|
|
163
|
+
if cell_area <= 0.0:
|
|
164
|
+
raise DatasetError("canonical grid contains a cell with nonpositive area")
|
|
165
|
+
union_area = (
|
|
166
|
+
float(unary_union(intersection_geometries).area) if intersection_geometries else 0.0
|
|
167
|
+
)
|
|
168
|
+
coverage_values.append(min(max(union_area / cell_area, 0.0), 1.0))
|
|
169
|
+
polygon_counts.append(intersecting_features)
|
|
170
|
+
|
|
171
|
+
if category_column is not None:
|
|
172
|
+
areas = {
|
|
173
|
+
category: float(unary_union(category_geometries[category]).area)
|
|
174
|
+
if category_geometries[category]
|
|
175
|
+
else 0.0
|
|
176
|
+
for category in category_values
|
|
177
|
+
}
|
|
178
|
+
for category in category_values:
|
|
179
|
+
category_ratios[category].append(min(max(areas[category] / cell_area, 0.0), 1.0))
|
|
180
|
+
present_categories = [category for category in category_values if areas[category] > 0]
|
|
181
|
+
dominant_values.append(
|
|
182
|
+
min(present_categories, key=lambda category: (-areas[category], category))
|
|
183
|
+
if present_categories
|
|
184
|
+
else None
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
for column in numeric_columns:
|
|
188
|
+
pairs = sorted(weighted_pairs[column])
|
|
189
|
+
total_area = math.fsum(area for _, area in pairs)
|
|
190
|
+
if total_area == 0:
|
|
191
|
+
weighted_values[column].append(None)
|
|
192
|
+
else:
|
|
193
|
+
numerator = math.fsum(value * area for value, area in pairs)
|
|
194
|
+
weighted_values[column].append(numerator / total_area)
|
|
195
|
+
|
|
196
|
+
result["polygon_count"] = polygon_counts
|
|
197
|
+
result["polygon_coverage_ratio"] = coverage_values
|
|
198
|
+
if category_column is not None:
|
|
199
|
+
result[f"dominant_{category_column}"] = dominant_values
|
|
200
|
+
for category, column in category_outputs.items():
|
|
201
|
+
result[column] = category_ratios[category]
|
|
202
|
+
for column, values in weighted_values.items():
|
|
203
|
+
result[f"{column}_weighted_mean"] = values
|
|
204
|
+
return result
|