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/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
|
gridforge/provenance.py
ADDED
|
@@ -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
|
+
}
|