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/join.py ADDED
@@ -0,0 +1,75 @@
1
+ """Safe feature joins for datasets sharing one canonical grid."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ import geopandas as gpd
8
+
9
+ from gridforge.errors import DatasetError
10
+ from gridforge.validation import validate_dataset, validate_grid
11
+
12
+ _GRID_COLUMNS = {
13
+ "grid_id",
14
+ "grid_fingerprint",
15
+ "row",
16
+ "column",
17
+ "left",
18
+ "bottom",
19
+ "right",
20
+ "top",
21
+ "geometry",
22
+ }
23
+
24
+
25
+ def _require_valid(report, label: str) -> None:
26
+ errors = [finding for finding in report.findings if finding.severity == "ERROR"]
27
+ if errors:
28
+ details = "; ".join(finding.message for finding in errors[:3])
29
+ if any(finding.code == "grid_fingerprint_mismatch" for finding in errors):
30
+ raise DatasetError(f"{label} grid fingerprint mismatch: {details}")
31
+ if any(finding.code == "crs_mismatch" for finding in errors) or (
32
+ "CRS does not match" in details
33
+ ):
34
+ raise DatasetError(f"{label} CRS mismatch: {details}")
35
+ if any(
36
+ finding.code in {"cell_geometry_mismatch", "alignment_mismatch"} for finding in errors
37
+ ):
38
+ raise DatasetError(f"{label} geometry mismatch: {details}")
39
+ raise DatasetError(f"{label} is invalid: {details}")
40
+
41
+
42
+ def join_features(
43
+ grid: gpd.GeoDataFrame,
44
+ *features: gpd.GeoDataFrame,
45
+ ) -> gpd.GeoDataFrame:
46
+ """Join complete aligned feature frames after verifying grid identity."""
47
+ _require_valid(validate_grid(grid), "canonical grid")
48
+ result = grid.copy()
49
+ seen: set[str] = set()
50
+ for index, feature in enumerate(features, start=1):
51
+ if isinstance(feature, gpd.GeoDataFrame) and {"row", "column"} <= set(feature.columns):
52
+ feature = feature.sort_values(["row", "column"], kind="stable")
53
+ _require_valid(validate_dataset(feature, grid=grid), f"feature dataset {index}")
54
+ columns: Sequence[str] = [
55
+ column for column in feature.columns if column not in _GRID_COLUMNS
56
+ ]
57
+ duplicates = sorted(seen.intersection(columns))
58
+ if duplicates:
59
+ raise DatasetError(
60
+ f"duplicate feature columns across aligned datasets: {', '.join(duplicates)}"
61
+ )
62
+ seen.update(columns)
63
+ if columns:
64
+ attributes = feature[["grid_id", *columns]]
65
+ result = result.merge(
66
+ attributes,
67
+ on="grid_id",
68
+ how="left",
69
+ sort=False,
70
+ validate="one_to_one",
71
+ )
72
+ result = result.sort_values(["row", "column"], kind="stable").reset_index(drop=True)
73
+ result.attrs["gridforge_spec"] = grid.attrs.get("gridforge_spec")
74
+ _require_valid(validate_dataset(result, grid=grid), "joined dataset")
75
+ return result
@@ -0,0 +1,77 @@
1
+ """Lightweight metadata for reproducible spatial transformations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import geopandas as gpd
11
+
12
+ from gridforge import __version__
13
+ from gridforge.errors import DatasetError
14
+ from gridforge.grid.spec import GridSpec
15
+
16
+
17
+ def write_provenance(
18
+ output_path: str | Path,
19
+ *,
20
+ operation: str,
21
+ source: str | list[str] | tuple[str, ...],
22
+ grid: gpd.GeoDataFrame,
23
+ parameters: dict[str, Any],
24
+ ) -> Path:
25
+ """Write a concise JSON sidecar describing one spatial transformation.
26
+
27
+ The sidecar records transformation inputs and policy, not source hashes,
28
+ full lineage, immutable release state, or artifact integrity.
29
+ """
30
+ spec_value = grid.attrs.get("gridforge_spec")
31
+ if not isinstance(spec_value, dict):
32
+ raise DatasetError("grid is missing GridForge specification metadata")
33
+ try:
34
+ spec = GridSpec.from_mapping(spec_value)
35
+ except ValueError as exc:
36
+ raise DatasetError(f"grid specification metadata is invalid: {exc}") from exc
37
+ fingerprints = grid.get("grid_fingerprint")
38
+ if fingerprints is None or fingerprints.empty:
39
+ raise DatasetError("grid is missing GridForge fingerprint")
40
+ fingerprint = str(fingerprints.iloc[0])
41
+ if fingerprint != spec.fingerprint or not fingerprints.astype(str).eq(fingerprint).all():
42
+ raise DatasetError("grid fingerprint does not match GridForge specification")
43
+
44
+ output = Path(output_path)
45
+ sidecar = Path(f"{output}.gridforge.json")
46
+ sidecar.parent.mkdir(parents=True, exist_ok=True)
47
+ source_crs = parameters.get("source_crs")
48
+ record = {
49
+ "gridforge_version": __version__,
50
+ "operation": operation,
51
+ "source": source,
52
+ "source_crs": source_crs,
53
+ "target_crs": spec.crs_id,
54
+ "grid_fingerprint": fingerprint,
55
+ "grid_spec": spec.to_dict(),
56
+ "parameters": parameters,
57
+ "created_at": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
58
+ "output": output.name,
59
+ }
60
+ temporary = sidecar.with_name(f".{sidecar.name}.tmp")
61
+ try:
62
+ serialized = json.dumps(
63
+ record,
64
+ ensure_ascii=False,
65
+ sort_keys=True,
66
+ indent=2,
67
+ allow_nan=False,
68
+ )
69
+ temporary.write_text(
70
+ serialized + "\n",
71
+ encoding="utf-8",
72
+ )
73
+ temporary.replace(sidecar)
74
+ except (OSError, TypeError, ValueError) as exc:
75
+ temporary.unlink(missing_ok=True)
76
+ raise DatasetError(f"cannot write valid provenance sidecar {sidecar}: {exc}") from exc
77
+ return sidecar
@@ -0,0 +1,11 @@
1
+ """Validation API for canonical grids and aligned datasets."""
2
+
3
+ from gridforge.validation.dataset import validate_dataset, validate_grid
4
+ from gridforge.validation.report import ValidationFinding, ValidationReport
5
+
6
+ __all__ = [
7
+ "ValidationFinding",
8
+ "ValidationReport",
9
+ "validate_dataset",
10
+ "validate_grid",
11
+ ]
@@ -0,0 +1,376 @@
1
+ """Grid and aligned-dataset contract validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ import geopandas as gpd
8
+ import numpy as np
9
+ import pandas as pd
10
+ from shapely.geometry import box
11
+
12
+ from gridforge.grid.build import get_grid_spec
13
+ from gridforge.validation.report import ValidationFinding, ValidationReport
14
+
15
+ _REQUIRED_GRID_COLUMNS = {
16
+ "grid_id",
17
+ "grid_fingerprint",
18
+ "row",
19
+ "column",
20
+ "left",
21
+ "bottom",
22
+ "right",
23
+ "top",
24
+ "geometry",
25
+ }
26
+ _SPATIAL_COLUMNS = _REQUIRED_GRID_COLUMNS
27
+
28
+
29
+ def _append(
30
+ findings: list[ValidationFinding],
31
+ severity: Literal["ERROR", "WARNING"],
32
+ code: str,
33
+ message: str,
34
+ count: int | None = None,
35
+ ) -> None:
36
+ findings.append(ValidationFinding(severity, code, message, count))
37
+
38
+
39
+ def _grid_structure(grid: gpd.GeoDataFrame, findings: list[ValidationFinding]) -> None:
40
+ missing_columns = sorted(_REQUIRED_GRID_COLUMNS - set(grid.columns))
41
+ if missing_columns:
42
+ _append(
43
+ findings,
44
+ "ERROR",
45
+ "required_columns",
46
+ f"grid is missing required columns: {', '.join(missing_columns)}",
47
+ )
48
+ return
49
+ if grid.empty:
50
+ _append(findings, "ERROR", "empty_grid", "grid must contain at least one cell")
51
+ return
52
+ if grid.crs is None:
53
+ _append(findings, "ERROR", "unknown_crs", "grid CRS is unknown")
54
+
55
+ if grid["grid_id"].isna().any():
56
+ _append(
57
+ findings,
58
+ "ERROR",
59
+ "null_grid_ids",
60
+ "grid IDs must be non-null",
61
+ int(grid["grid_id"].isna().sum()),
62
+ )
63
+ duplicates = int(grid["grid_id"].duplicated().sum())
64
+ if duplicates:
65
+ _append(
66
+ findings,
67
+ "ERROR",
68
+ "duplicate_grid_ids",
69
+ "grid contains duplicate grid IDs",
70
+ duplicates,
71
+ )
72
+
73
+ row_values = pd.to_numeric(grid["row"], errors="coerce").to_numpy(dtype=float, na_value=np.nan)
74
+ column_values = pd.to_numeric(grid["column"], errors="coerce").to_numpy(
75
+ dtype=float, na_value=np.nan
76
+ )
77
+ valid_indexes = (
78
+ np.isfinite(row_values)
79
+ & np.isfinite(column_values)
80
+ & (row_values == np.floor(row_values))
81
+ & (column_values == np.floor(column_values))
82
+ )
83
+ invalid_indexes = int((~valid_indexes).sum())
84
+ if invalid_indexes:
85
+ _append(
86
+ findings,
87
+ "ERROR",
88
+ "invalid_grid_indices",
89
+ "grid row and column indices must be finite integers",
90
+ invalid_indexes,
91
+ )
92
+
93
+ try:
94
+ spec = get_grid_spec(grid)
95
+ except Exception as exc:
96
+ code = "crs_mismatch" if "grid CRS does not match" in str(exc) else "grid_fingerprint"
97
+ _append(findings, "ERROR", code, f"grid specification is invalid: {exc}")
98
+ return
99
+
100
+ expected_fingerprint = spec.fingerprint
101
+ fingerprint_mismatch = int(
102
+ grid["grid_fingerprint"].isna().sum()
103
+ + (~grid["grid_fingerprint"].astype(str).eq(expected_fingerprint)).sum()
104
+ )
105
+ if fingerprint_mismatch:
106
+ _append(
107
+ findings,
108
+ "ERROR",
109
+ "grid_fingerprint",
110
+ "grid rows do not match the specification fingerprint",
111
+ fingerprint_mismatch,
112
+ )
113
+
114
+ row_start, row_end, col_start, col_end = spec.index_extent
115
+ expected_ids = {
116
+ f"{row}:{column}"
117
+ for row in range(row_start, row_end + 1)
118
+ for column in range(col_start, col_end + 1)
119
+ }
120
+ observed_ids = set(grid["grid_id"].dropna().astype(str))
121
+ missing_ids = sorted(expected_ids - observed_ids)
122
+ extra_ids = sorted(observed_ids - expected_ids)
123
+ if missing_ids:
124
+ _append(
125
+ findings,
126
+ "ERROR",
127
+ "missing_grid_ids",
128
+ "grid is missing canonical grid IDs",
129
+ len(missing_ids),
130
+ )
131
+ if extra_ids:
132
+ _append(
133
+ findings,
134
+ "ERROR",
135
+ "unexpected_grid_ids",
136
+ "grid contains unexpected grid IDs outside its canonical extent",
137
+ len(extra_ids),
138
+ )
139
+
140
+ safe_rows = np.where(np.isfinite(row_values), row_values, np.inf)
141
+ safe_columns = np.where(np.isfinite(column_values), column_values, np.inf)
142
+ order = np.lexsort((safe_columns, safe_rows))
143
+ if not np.array_equal(order, np.arange(len(grid))):
144
+ _append(findings, "ERROR", "grid_order", "grid rows are not sorted by row and column")
145
+
146
+ outside_count = 0
147
+ invalid_geometry_count = 0
148
+ geometry_mismatch_count = 0
149
+ bounds_mismatch_count = 0
150
+ expected_min_x = float(spec.origin.x + col_start * spec.cell_size)
151
+ expected_max_x = float(spec.origin.x + (col_end + 1) * spec.cell_size)
152
+ expected_min_y = float(spec.origin.y - (row_end + 1) * spec.cell_size)
153
+ expected_max_y = float(spec.origin.y - row_start * spec.cell_size)
154
+ expected_extent = (expected_min_x, expected_min_y, expected_max_x, expected_max_y)
155
+ for position, record in enumerate(grid.itertuples(index=False)):
156
+ if not valid_indexes[position]:
157
+ outside_count += 1
158
+ continue
159
+ row = int(row_values[position])
160
+ column = int(column_values[position])
161
+ tolerance = float(spec.boundary_tolerance)
162
+ try:
163
+ actual_bounds = tuple(
164
+ float(value) for value in (record.left, record.bottom, record.right, record.top)
165
+ )
166
+ except (TypeError, ValueError):
167
+ actual_bounds = (np.nan, np.nan, np.nan, np.nan)
168
+ valid_bounds = bool(np.isfinite(actual_bounds).all())
169
+ if not valid_bounds:
170
+ outside_count += 1
171
+ bounds_mismatch_count += 1
172
+ elif (
173
+ not (row_start <= row <= row_end and col_start <= column <= col_end)
174
+ or actual_bounds[0] < expected_extent[0] - tolerance
175
+ or actual_bounds[2] > expected_extent[2] + tolerance
176
+ or actual_bounds[1] < expected_extent[1] - tolerance
177
+ or actual_bounds[3] > expected_extent[3] + tolerance
178
+ ):
179
+ outside_count += 1
180
+ if str(record.grid_id) != f"{row}:{column}":
181
+ geometry_mismatch_count += 1
182
+ geometry = record.geometry
183
+ if geometry is None or geometry.is_empty or not geometry.is_valid:
184
+ invalid_geometry_count += 1
185
+ continue
186
+ expected_left = float(spec.origin.x + column * spec.cell_size)
187
+ expected_right = float(spec.origin.x + (column + 1) * spec.cell_size)
188
+ expected_top = float(spec.origin.y - row * spec.cell_size)
189
+ expected_bottom = float(spec.origin.y - (row + 1) * spec.cell_size)
190
+ expected_geometry = box(expected_left, expected_bottom, expected_right, expected_top)
191
+ if not geometry.equals(expected_geometry):
192
+ geometry_mismatch_count += 1
193
+ if valid_bounds:
194
+ for actual, expected in zip(
195
+ actual_bounds,
196
+ (expected_left, expected_bottom, expected_right, expected_top),
197
+ strict=True,
198
+ ):
199
+ if not np.isclose(actual, expected, rtol=0.0, atol=tolerance):
200
+ bounds_mismatch_count += 1
201
+ break
202
+ if outside_count:
203
+ _append(
204
+ findings,
205
+ "ERROR",
206
+ "outside_extent",
207
+ "grid contains cells outside its canonical extent",
208
+ outside_count,
209
+ )
210
+ if invalid_geometry_count:
211
+ _append(
212
+ findings,
213
+ "ERROR",
214
+ "invalid_geometry",
215
+ "grid contains null, empty, or invalid cell geometry",
216
+ invalid_geometry_count,
217
+ )
218
+ if geometry_mismatch_count:
219
+ _append(
220
+ findings,
221
+ "ERROR",
222
+ "cell_geometry_mismatch",
223
+ "grid IDs or geometries do not match canonical row and column indices",
224
+ geometry_mismatch_count,
225
+ )
226
+ if bounds_mismatch_count:
227
+ _append(
228
+ findings,
229
+ "ERROR",
230
+ "cell_bounds_mismatch",
231
+ "cell bounds do not match canonical row and column indices",
232
+ bounds_mismatch_count,
233
+ )
234
+
235
+
236
+ def _value_statistics(
237
+ dataset: gpd.GeoDataFrame,
238
+ findings: list[ValidationFinding],
239
+ ) -> None:
240
+ for column in dataset.columns:
241
+ if column in _SPATIAL_COLUMNS:
242
+ continue
243
+ values = dataset[column]
244
+ null_count = int(values.isna().sum())
245
+ if null_count:
246
+ _append(
247
+ findings,
248
+ "WARNING",
249
+ "null_values",
250
+ f"column {column!r} contains null or nodata values",
251
+ null_count,
252
+ )
253
+ if "coverage_ratio" in column:
254
+ try:
255
+ numeric = pd.to_numeric(values, errors="coerce").to_numpy(dtype=float)
256
+ except (TypeError, ValueError):
257
+ numeric = np.full(len(values), np.nan)
258
+ invalid = (~np.isfinite(numeric)) | (numeric < -1e-9) | (numeric > 1.0 + 1e-9)
259
+ if invalid.any():
260
+ _append(
261
+ findings,
262
+ "ERROR",
263
+ "coverage_ratio",
264
+ f"column {column!r} contains null, non-finite, or out-of-range coverage ratios",
265
+ int(invalid.sum()),
266
+ )
267
+ if pd.api.types.is_numeric_dtype(values.dtype):
268
+ try:
269
+ numeric = values.to_numpy(dtype=float, na_value=np.nan)
270
+ nonfinite = (~np.isfinite(numeric)) & (~np.isnan(numeric))
271
+ except (TypeError, ValueError):
272
+ continue
273
+ if nonfinite.any():
274
+ _append(
275
+ findings,
276
+ "ERROR",
277
+ "nonfinite_values",
278
+ f"column {column!r} contains infinite values",
279
+ int(nonfinite.sum()),
280
+ )
281
+
282
+
283
+ def validate_grid(grid: gpd.GeoDataFrame) -> ValidationReport:
284
+ """Validate a canonical grid and return structured findings."""
285
+ findings: list[ValidationFinding] = []
286
+ if not isinstance(grid, gpd.GeoDataFrame):
287
+ _append(findings, "ERROR", "not_geodataframe", "grid must be a GeoDataFrame")
288
+ return ValidationReport(tuple(findings))
289
+ _grid_structure(grid, findings)
290
+ return ValidationReport(tuple(findings))
291
+
292
+
293
+ def validate_dataset(
294
+ dataset: gpd.GeoDataFrame,
295
+ *,
296
+ grid: gpd.GeoDataFrame | None = None,
297
+ ) -> ValidationReport:
298
+ """Validate one complete aligned dataset, optionally against a canonical grid."""
299
+ findings: list[ValidationFinding] = []
300
+ if not isinstance(dataset, gpd.GeoDataFrame):
301
+ _append(findings, "ERROR", "not_geodataframe", "dataset must be a GeoDataFrame")
302
+ return ValidationReport(tuple(findings))
303
+ _grid_structure(dataset, findings)
304
+ if grid is not None:
305
+ if not isinstance(grid, gpd.GeoDataFrame):
306
+ _append(findings, "ERROR", "invalid_grid", "comparison grid must be a GeoDataFrame")
307
+ else:
308
+ grid_report = validate_grid(grid)
309
+ findings.extend(grid_report.findings)
310
+ if dataset.crs is None or grid.crs is None or not dataset.crs.equals(grid.crs):
311
+ _append(
312
+ findings,
313
+ "ERROR",
314
+ "crs_mismatch",
315
+ "dataset CRS does not match canonical grid CRS",
316
+ )
317
+ dataset_fingerprints = set(
318
+ dataset.get("grid_fingerprint", pd.Series(dtype=str)).dropna().astype(str)
319
+ )
320
+ grid_fingerprints = set(
321
+ grid.get("grid_fingerprint", pd.Series(dtype=str)).dropna().astype(str)
322
+ )
323
+ if dataset_fingerprints != grid_fingerprints or len(grid_fingerprints) != 1:
324
+ _append(
325
+ findings,
326
+ "ERROR",
327
+ "grid_fingerprint_mismatch",
328
+ "dataset grid fingerprint does not match canonical grid",
329
+ )
330
+ if grid_report.exit_code == 0 and "grid_id" in dataset and "grid_id" in grid:
331
+ if dataset["grid_id"].duplicated().any():
332
+ pass
333
+ else:
334
+ expected = set(grid["grid_id"].dropna().astype(str))
335
+ observed = set(dataset["grid_id"].dropna().astype(str))
336
+ missing = expected - observed
337
+ extra = observed - expected
338
+ if missing:
339
+ _append(
340
+ findings,
341
+ "ERROR",
342
+ "missing_grid_ids",
343
+ "dataset is missing grid IDs from the canonical grid",
344
+ len(missing),
345
+ )
346
+ if extra:
347
+ _append(
348
+ findings,
349
+ "ERROR",
350
+ "unexpected_grid_ids",
351
+ "dataset contains grid IDs outside the canonical grid",
352
+ len(extra),
353
+ )
354
+ if not missing and not extra:
355
+ expected_by_id = grid.set_index("grid_id")
356
+ actual_by_id = dataset.set_index("grid_id")
357
+ mismatch_count = 0
358
+ for grid_id in expected:
359
+ actual_geometry = actual_by_id.loc[grid_id].geometry
360
+ expected_geometry = expected_by_id.loc[grid_id].geometry
361
+ if (
362
+ actual_geometry is None
363
+ or expected_geometry is None
364
+ or not actual_geometry.equals(expected_geometry)
365
+ ):
366
+ mismatch_count += 1
367
+ if mismatch_count:
368
+ _append(
369
+ findings,
370
+ "ERROR",
371
+ "alignment_mismatch",
372
+ "dataset cell geometry does not match canonical grid",
373
+ mismatch_count,
374
+ )
375
+ _value_statistics(dataset, findings)
376
+ return ValidationReport(tuple(findings))
@@ -0,0 +1,41 @@
1
+ """Machine-readable validation findings and exit status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from typing import Literal
7
+
8
+ Severity = Literal["ERROR", "WARNING"]
9
+ ValidationStatus = Literal["PASS", "WARNING", "ERROR"]
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ValidationFinding:
14
+ severity: Severity
15
+ code: str
16
+ message: str
17
+ count: int | None = None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ValidationReport:
22
+ findings: tuple[ValidationFinding, ...]
23
+
24
+ @property
25
+ def status(self) -> ValidationStatus:
26
+ if any(finding.severity == "ERROR" for finding in self.findings):
27
+ return "ERROR"
28
+ if self.findings:
29
+ return "WARNING"
30
+ return "PASS"
31
+
32
+ @property
33
+ def exit_code(self) -> int:
34
+ return 2 if self.status == "ERROR" else 0
35
+
36
+ def to_dict(self) -> dict[str, object]:
37
+ return {
38
+ "status": self.status,
39
+ "exit_code": self.exit_code,
40
+ "findings": [asdict(finding) for finding in self.findings],
41
+ }