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