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/cli.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Command-line entry point for GridForge."""
|
|
2
|
+
|
|
3
|
+
# Typer expresses command parameters using function-call defaults.
|
|
4
|
+
# ruff: noqa: B008
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import geopandas as gpd
|
|
13
|
+
import typer
|
|
14
|
+
from pyproj import CRS
|
|
15
|
+
|
|
16
|
+
from gridforge import __version__
|
|
17
|
+
from gridforge.align.points import align_points
|
|
18
|
+
from gridforge.align.raster import align_raster
|
|
19
|
+
from gridforge.align.vector import align_polygons
|
|
20
|
+
from gridforge.demo import run_demo
|
|
21
|
+
from gridforge.errors import GridForgeError
|
|
22
|
+
from gridforge.grid.build import create_grid
|
|
23
|
+
from gridforge.grid.spec import GridSpec
|
|
24
|
+
from gridforge.io.datasets import (
|
|
25
|
+
_crs_label,
|
|
26
|
+
inspect_dataset,
|
|
27
|
+
load_points,
|
|
28
|
+
load_vector,
|
|
29
|
+
read_dataset,
|
|
30
|
+
write_dataset,
|
|
31
|
+
)
|
|
32
|
+
from gridforge.io.grid import read_grid, write_grid
|
|
33
|
+
from gridforge.join import join_features
|
|
34
|
+
from gridforge.provenance import write_provenance
|
|
35
|
+
from gridforge.validation import validate_dataset
|
|
36
|
+
|
|
37
|
+
app = typer.Typer(
|
|
38
|
+
add_completion=False,
|
|
39
|
+
no_args_is_help=True,
|
|
40
|
+
help="Build, align, join, and validate spatial grid datasets.",
|
|
41
|
+
)
|
|
42
|
+
grid_app = typer.Typer(no_args_is_help=True, help="Create canonical grids.")
|
|
43
|
+
align_app = typer.Typer(no_args_is_help=True, help="Align source data to a canonical grid.")
|
|
44
|
+
app.add_typer(grid_app, name="grid")
|
|
45
|
+
app.add_typer(align_app, name="align")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _show_version(value: bool) -> None:
|
|
49
|
+
if value:
|
|
50
|
+
typer.echo(__version__)
|
|
51
|
+
raise typer.Exit()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.callback()
|
|
55
|
+
def main(
|
|
56
|
+
version: bool = typer.Option(
|
|
57
|
+
False,
|
|
58
|
+
"--version",
|
|
59
|
+
callback=_show_version,
|
|
60
|
+
is_eager=True,
|
|
61
|
+
help="Show the installed GridForge version and exit.",
|
|
62
|
+
),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Create and validate deterministic grid-based spatial datasets."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run(action):
|
|
68
|
+
try:
|
|
69
|
+
return action()
|
|
70
|
+
except (GridForgeError, OSError) as exc:
|
|
71
|
+
typer.echo(f"ERROR: {exc}", err=True)
|
|
72
|
+
raise typer.Exit(code=2) from exc
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _crs_arg(crs: Any) -> str | None:
|
|
76
|
+
if crs is None:
|
|
77
|
+
return None
|
|
78
|
+
return _crs_label(CRS.from_user_input(crs))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _print_json(value: Any) -> None:
|
|
82
|
+
typer.echo(json.dumps(value, ensure_ascii=False, indent=2, default=str))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _write_aligned(
|
|
86
|
+
dataset: gpd.GeoDataFrame,
|
|
87
|
+
output: Path,
|
|
88
|
+
*,
|
|
89
|
+
operation: str,
|
|
90
|
+
source: str | list[str],
|
|
91
|
+
grid: gpd.GeoDataFrame,
|
|
92
|
+
parameters: dict[str, Any],
|
|
93
|
+
) -> None:
|
|
94
|
+
write_dataset(dataset, output)
|
|
95
|
+
write_provenance(
|
|
96
|
+
output,
|
|
97
|
+
operation=operation,
|
|
98
|
+
source=source,
|
|
99
|
+
grid=grid,
|
|
100
|
+
parameters=parameters,
|
|
101
|
+
)
|
|
102
|
+
typer.echo(f"Wrote {output}")
|
|
103
|
+
typer.echo(f"Wrote {output}.gridforge.json")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command("inspect")
|
|
107
|
+
def inspect_command(
|
|
108
|
+
source: Path = typer.Argument(..., exists=True, readable=True),
|
|
109
|
+
source_crs: str | None = typer.Option(None, "--source-crs"),
|
|
110
|
+
x_column: str = typer.Option("x", "--x-column"),
|
|
111
|
+
y_column: str = typer.Option("y", "--y-column"),
|
|
112
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Inspect spatial metadata without guessing a missing source CRS."""
|
|
115
|
+
info = _run(
|
|
116
|
+
lambda: inspect_dataset(
|
|
117
|
+
source,
|
|
118
|
+
source_crs=source_crs,
|
|
119
|
+
x_column=x_column,
|
|
120
|
+
y_column=y_column,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
if json_output:
|
|
124
|
+
_print_json(info.to_dict())
|
|
125
|
+
return
|
|
126
|
+
typer.echo(f"Path: {info.path}")
|
|
127
|
+
typer.echo(f"Kind: {info.kind}")
|
|
128
|
+
typer.echo(f"Geometry: {info.geometry_type or 'unknown'}")
|
|
129
|
+
typer.echo(f"CRS: {info.crs}")
|
|
130
|
+
typer.echo(f"Features: {info.feature_count if info.feature_count is not None else 'n/a'}")
|
|
131
|
+
typer.echo(f"Bands: {info.band_count if info.band_count is not None else 'n/a'}")
|
|
132
|
+
typer.echo(f"Bounds: {info.bounds if info.bounds is not None else 'n/a'}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@grid_app.command("create")
|
|
136
|
+
def grid_create(
|
|
137
|
+
specification: Path = typer.Argument(..., exists=True, readable=True),
|
|
138
|
+
output: Path = typer.Option(..., "--output", "-o"),
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Create a deterministic GeoParquet grid from a YAML specification."""
|
|
141
|
+
|
|
142
|
+
def action() -> None:
|
|
143
|
+
spec = GridSpec.from_yaml(specification)
|
|
144
|
+
grid = create_grid(spec)
|
|
145
|
+
write_grid(grid, output)
|
|
146
|
+
write_provenance(
|
|
147
|
+
output,
|
|
148
|
+
operation="grid_build",
|
|
149
|
+
source=str(specification),
|
|
150
|
+
grid=grid,
|
|
151
|
+
parameters={"grid_spec": spec.to_dict()},
|
|
152
|
+
)
|
|
153
|
+
typer.echo(f"Created {len(grid)} cells: {output}")
|
|
154
|
+
typer.echo(f"Wrote {output}.gridforge.json")
|
|
155
|
+
|
|
156
|
+
_run(action)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@align_app.command("points")
|
|
160
|
+
def align_points_command(
|
|
161
|
+
source: Path = typer.Argument(..., exists=True, readable=True),
|
|
162
|
+
grid_path: Path = typer.Option(..., "--grid"),
|
|
163
|
+
value: str = typer.Option(..., "--value"),
|
|
164
|
+
aggregation: str = typer.Option("mean", "--agg"),
|
|
165
|
+
output: Path = typer.Option(..., "--output", "-o"),
|
|
166
|
+
source_crs: str | None = typer.Option(None, "--source-crs"),
|
|
167
|
+
x_column: str = typer.Option("x", "--x-column"),
|
|
168
|
+
y_column: str = typer.Option("y", "--y-column"),
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Aggregate one point attribute into canonical cells."""
|
|
171
|
+
|
|
172
|
+
def action() -> None:
|
|
173
|
+
grid = read_grid(grid_path)
|
|
174
|
+
points = load_points(
|
|
175
|
+
source,
|
|
176
|
+
x_column=x_column,
|
|
177
|
+
y_column=y_column,
|
|
178
|
+
source_crs=source_crs,
|
|
179
|
+
)
|
|
180
|
+
result = align_points(
|
|
181
|
+
points,
|
|
182
|
+
grid,
|
|
183
|
+
aggregations={value: aggregation},
|
|
184
|
+
source_crs=source_crs,
|
|
185
|
+
)
|
|
186
|
+
_write_aligned(
|
|
187
|
+
result,
|
|
188
|
+
output,
|
|
189
|
+
operation="point_alignment",
|
|
190
|
+
source=str(source),
|
|
191
|
+
grid=grid,
|
|
192
|
+
parameters={
|
|
193
|
+
"source_crs": _crs_arg(points.crs),
|
|
194
|
+
"target_crs": _crs_arg(grid.crs),
|
|
195
|
+
"x_column": x_column,
|
|
196
|
+
"y_column": y_column,
|
|
197
|
+
"aggregation": {value: aggregation},
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
_run(action)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@align_app.command("vector")
|
|
205
|
+
def align_vector_command(
|
|
206
|
+
source: Path = typer.Argument(..., exists=True, readable=True),
|
|
207
|
+
grid_path: Path = typer.Option(..., "--grid"),
|
|
208
|
+
output: Path = typer.Option(..., "--output", "-o"),
|
|
209
|
+
category: str | None = typer.Option(None, "--category"),
|
|
210
|
+
numeric_column: list[str] = typer.Option([], "--numeric-column"),
|
|
211
|
+
source_crs: str | None = typer.Option(None, "--source-crs"),
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Aggregate polygon coverage, categories, and numeric attributes."""
|
|
214
|
+
|
|
215
|
+
def action() -> None:
|
|
216
|
+
grid = read_grid(grid_path)
|
|
217
|
+
vector = load_vector(source, source_crs=source_crs)
|
|
218
|
+
result = align_polygons(
|
|
219
|
+
vector,
|
|
220
|
+
grid,
|
|
221
|
+
category_column=category,
|
|
222
|
+
numeric_columns=numeric_column,
|
|
223
|
+
source_crs=source_crs,
|
|
224
|
+
)
|
|
225
|
+
_write_aligned(
|
|
226
|
+
result,
|
|
227
|
+
output,
|
|
228
|
+
operation="vector_alignment",
|
|
229
|
+
source=str(source),
|
|
230
|
+
grid=grid,
|
|
231
|
+
parameters={
|
|
232
|
+
"source_crs": _crs_arg(vector.crs),
|
|
233
|
+
"target_crs": _crs_arg(grid.crs),
|
|
234
|
+
"category_column": category,
|
|
235
|
+
"numeric_columns": numeric_column,
|
|
236
|
+
"coverage": "union area / cell area",
|
|
237
|
+
"weighted_aggregation": "intersection-area weighted mean",
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
_run(action)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@align_app.command("raster")
|
|
245
|
+
def align_raster_command(
|
|
246
|
+
source: Path = typer.Argument(..., exists=True, readable=True),
|
|
247
|
+
grid_path: Path = typer.Option(..., "--grid"),
|
|
248
|
+
aggregation: str = typer.Option(..., "--agg"),
|
|
249
|
+
output: Path = typer.Option(..., "--output", "-o"),
|
|
250
|
+
band: int = typer.Option(1, "--band", min=1),
|
|
251
|
+
source_crs: str | None = typer.Option(None, "--source-crs"),
|
|
252
|
+
resampling: str = typer.Option("nearest", "--resampling"),
|
|
253
|
+
value_name: str | None = typer.Option(None, "--value-name"),
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Reproject and aggregate one raster band into canonical cells."""
|
|
256
|
+
|
|
257
|
+
def action() -> None:
|
|
258
|
+
grid = read_grid(grid_path)
|
|
259
|
+
result = align_raster(
|
|
260
|
+
source,
|
|
261
|
+
grid,
|
|
262
|
+
aggregation=aggregation,
|
|
263
|
+
band=band,
|
|
264
|
+
source_crs=source_crs,
|
|
265
|
+
resampling=resampling,
|
|
266
|
+
value_name=value_name,
|
|
267
|
+
)
|
|
268
|
+
operation = result.attrs.get("gridforge_operation", {})
|
|
269
|
+
_write_aligned(
|
|
270
|
+
result,
|
|
271
|
+
output,
|
|
272
|
+
operation="raster_alignment",
|
|
273
|
+
source=str(source),
|
|
274
|
+
grid=grid,
|
|
275
|
+
parameters={
|
|
276
|
+
"source_crs": operation.get("source_crs"),
|
|
277
|
+
"target_crs": operation.get("target_crs"),
|
|
278
|
+
"band": band,
|
|
279
|
+
"aggregation": aggregation,
|
|
280
|
+
"aggregation_resampling": operation.get("aggregation_resampling"),
|
|
281
|
+
"resampling": resampling,
|
|
282
|
+
"nodata": operation.get("nodata"),
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
_run(action)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.command("join")
|
|
290
|
+
def join_command(
|
|
291
|
+
grid_path: Path = typer.Argument(..., exists=True, readable=True),
|
|
292
|
+
feature_paths: list[Path] = typer.Argument(..., exists=True, readable=True),
|
|
293
|
+
output: Path = typer.Option(..., "--output", "-o"),
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Join aligned GeoParquet feature datasets after identity validation."""
|
|
296
|
+
|
|
297
|
+
def action() -> None:
|
|
298
|
+
grid = read_grid(grid_path)
|
|
299
|
+
features = [read_dataset(path) for path in feature_paths]
|
|
300
|
+
result = join_features(grid, *features)
|
|
301
|
+
_write_aligned(
|
|
302
|
+
result,
|
|
303
|
+
output,
|
|
304
|
+
operation="feature_join",
|
|
305
|
+
source=[str(grid_path), *(str(path) for path in feature_paths)],
|
|
306
|
+
grid=grid,
|
|
307
|
+
parameters={"feature_datasets": len(features)},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
_run(action)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@app.command("validate")
|
|
314
|
+
def validate_command(
|
|
315
|
+
dataset_path: Path = typer.Argument(..., exists=True, readable=True),
|
|
316
|
+
grid_path: Path | None = typer.Option(None, "--grid"),
|
|
317
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
318
|
+
) -> None:
|
|
319
|
+
"""Validate one GridForge GeoParquet dataset; errors exit with status 2."""
|
|
320
|
+
|
|
321
|
+
def action():
|
|
322
|
+
dataset = read_dataset(dataset_path)
|
|
323
|
+
grid = read_grid(grid_path) if grid_path is not None else None
|
|
324
|
+
return validate_dataset(dataset, grid=grid)
|
|
325
|
+
|
|
326
|
+
report = _run(action)
|
|
327
|
+
if json_output:
|
|
328
|
+
_print_json(report.to_dict())
|
|
329
|
+
else:
|
|
330
|
+
typer.echo(report.status)
|
|
331
|
+
for finding in report.findings:
|
|
332
|
+
count = f" ({finding.count})" if finding.count is not None else ""
|
|
333
|
+
typer.echo(f"{finding.severity}: {finding.message}{count}")
|
|
334
|
+
if report.exit_code:
|
|
335
|
+
raise typer.Exit(code=report.exit_code)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@app.command("demo")
|
|
339
|
+
def demo_command(
|
|
340
|
+
output_dir: Path = typer.Option(Path("gridforge-demo"), "--output-dir"),
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Run a small, synthetic point/vector/raster-to-grid workflow."""
|
|
343
|
+
report = _run(lambda: run_demo(output_dir))
|
|
344
|
+
_print_json(report.to_dict())
|
|
345
|
+
typer.echo(f"Demo complete: {output_dir.resolve()}")
|
gridforge/demo.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Synthetic end-to-end workflow for the GridForge command-line demo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import geopandas as gpd
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import rasterio
|
|
11
|
+
from affine import Affine
|
|
12
|
+
from shapely.geometry import box
|
|
13
|
+
|
|
14
|
+
from gridforge.align.points import align_points
|
|
15
|
+
from gridforge.align.raster import align_raster
|
|
16
|
+
from gridforge.align.vector import align_polygons
|
|
17
|
+
from gridforge.errors import DatasetError
|
|
18
|
+
from gridforge.grid.build import create_grid
|
|
19
|
+
from gridforge.grid.spec import GridSpec
|
|
20
|
+
from gridforge.io.datasets import load_points, write_dataset
|
|
21
|
+
from gridforge.io.grid import write_grid
|
|
22
|
+
from gridforge.join import join_features
|
|
23
|
+
from gridforge.provenance import write_provenance
|
|
24
|
+
from gridforge.validation import ValidationReport, validate_dataset
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write_aligned(
|
|
28
|
+
dataset: gpd.GeoDataFrame,
|
|
29
|
+
path: Path,
|
|
30
|
+
*,
|
|
31
|
+
operation: str,
|
|
32
|
+
source: str | list[str],
|
|
33
|
+
grid: gpd.GeoDataFrame,
|
|
34
|
+
parameters: dict[str, object],
|
|
35
|
+
) -> None:
|
|
36
|
+
write_dataset(dataset, path)
|
|
37
|
+
write_provenance(
|
|
38
|
+
path,
|
|
39
|
+
operation=operation,
|
|
40
|
+
source=source,
|
|
41
|
+
grid=grid,
|
|
42
|
+
parameters=parameters,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_demo(output_dir: str | Path) -> ValidationReport:
|
|
47
|
+
"""Create synthetic spatial inputs and run the complete v0.1 workflow."""
|
|
48
|
+
directory = Path(output_dir)
|
|
49
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
specification_path = directory / "grid.yaml"
|
|
51
|
+
specification_path.write_text(
|
|
52
|
+
"""crs: EPSG:3857
|
|
53
|
+
cell_size: 20
|
|
54
|
+
bounds:
|
|
55
|
+
min_x: 0
|
|
56
|
+
min_y: 0
|
|
57
|
+
max_x: 80
|
|
58
|
+
max_y: 80
|
|
59
|
+
origin:
|
|
60
|
+
x: 0
|
|
61
|
+
y: 80
|
|
62
|
+
""",
|
|
63
|
+
encoding="utf-8",
|
|
64
|
+
)
|
|
65
|
+
spec = GridSpec.from_yaml(specification_path)
|
|
66
|
+
grid = create_grid(spec)
|
|
67
|
+
grid_path = directory / "grid.parquet"
|
|
68
|
+
write_grid(grid, grid_path)
|
|
69
|
+
write_provenance(
|
|
70
|
+
grid_path,
|
|
71
|
+
operation="grid_build",
|
|
72
|
+
source=str(specification_path),
|
|
73
|
+
grid=grid,
|
|
74
|
+
parameters={"grid_spec": spec.to_dict()},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
points_path = directory / "points.csv"
|
|
78
|
+
pd.DataFrame(
|
|
79
|
+
{
|
|
80
|
+
"station": ["P-01", "P-02", "P-03"],
|
|
81
|
+
"x": [10.0, 30.0, 70.0],
|
|
82
|
+
"y": [70.0, 50.0, 10.0],
|
|
83
|
+
"rainfall_mm": [12.0, 18.0, 9.0],
|
|
84
|
+
}
|
|
85
|
+
).to_csv(points_path, index=False)
|
|
86
|
+
point_frame = load_points(points_path, source_crs="EPSG:3857")
|
|
87
|
+
point_output = directory / "rainfall.parquet"
|
|
88
|
+
point_result = align_points(
|
|
89
|
+
point_frame,
|
|
90
|
+
grid,
|
|
91
|
+
aggregations={"rainfall_mm": "mean"},
|
|
92
|
+
)
|
|
93
|
+
_write_aligned(
|
|
94
|
+
point_result,
|
|
95
|
+
point_output,
|
|
96
|
+
operation="point_alignment",
|
|
97
|
+
source=str(points_path),
|
|
98
|
+
grid=grid,
|
|
99
|
+
parameters={"source_crs": "EPSG:3857", "aggregation": {"rainfall_mm": "mean"}},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
polygon_path = directory / "landuse.geojson"
|
|
103
|
+
polygons = gpd.GeoDataFrame(
|
|
104
|
+
{"landuse": ["park", "built"], "impervious": [0.1, 0.9]},
|
|
105
|
+
geometry=[box(0, 40, 40, 80), box(40, 0, 80, 40)],
|
|
106
|
+
crs="EPSG:3857",
|
|
107
|
+
)
|
|
108
|
+
polygons.to_file(polygon_path, driver="GeoJSON")
|
|
109
|
+
polygon_output = directory / "landuse.parquet"
|
|
110
|
+
polygon_result = align_polygons(
|
|
111
|
+
polygons,
|
|
112
|
+
grid,
|
|
113
|
+
category_column="landuse",
|
|
114
|
+
numeric_columns=("impervious",),
|
|
115
|
+
)
|
|
116
|
+
_write_aligned(
|
|
117
|
+
polygon_result,
|
|
118
|
+
polygon_output,
|
|
119
|
+
operation="vector_alignment",
|
|
120
|
+
source=str(polygon_path),
|
|
121
|
+
grid=grid,
|
|
122
|
+
parameters={
|
|
123
|
+
"source_crs": "EPSG:3857",
|
|
124
|
+
"category_column": "landuse",
|
|
125
|
+
"numeric_columns": ["impervious"],
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
raster_path = directory / "elevation.tif"
|
|
130
|
+
transform = Affine.translation(0, 80) @ Affine.scale(20, -20)
|
|
131
|
+
with rasterio.open(
|
|
132
|
+
raster_path,
|
|
133
|
+
"w",
|
|
134
|
+
driver="GTiff",
|
|
135
|
+
height=4,
|
|
136
|
+
width=4,
|
|
137
|
+
count=1,
|
|
138
|
+
dtype="float32",
|
|
139
|
+
crs="EPSG:3857",
|
|
140
|
+
transform=transform,
|
|
141
|
+
nodata=-9999.0,
|
|
142
|
+
) as raster:
|
|
143
|
+
raster.write(np.arange(16, dtype="float32").reshape(4, 4), 1)
|
|
144
|
+
raster_output = directory / "elevation.parquet"
|
|
145
|
+
raster_result = align_raster(raster_path, grid, aggregation="mean")
|
|
146
|
+
_write_aligned(
|
|
147
|
+
raster_result,
|
|
148
|
+
raster_output,
|
|
149
|
+
operation="raster_alignment",
|
|
150
|
+
source=str(raster_path),
|
|
151
|
+
grid=grid,
|
|
152
|
+
parameters={
|
|
153
|
+
"source_crs": "EPSG:3857",
|
|
154
|
+
"aggregation": "mean",
|
|
155
|
+
"resampling": "nearest",
|
|
156
|
+
"band": 1,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
joined = join_features(grid, point_result, polygon_result, raster_result)
|
|
161
|
+
joined_path = directory / "features.parquet"
|
|
162
|
+
_write_aligned(
|
|
163
|
+
joined,
|
|
164
|
+
joined_path,
|
|
165
|
+
operation="feature_join",
|
|
166
|
+
source=[str(point_output), str(polygon_output), str(raster_output)],
|
|
167
|
+
grid=grid,
|
|
168
|
+
parameters={"feature_datasets": 3},
|
|
169
|
+
)
|
|
170
|
+
report = validate_dataset(joined, grid=grid)
|
|
171
|
+
if report.exit_code:
|
|
172
|
+
raise DatasetError("synthetic demo output did not pass validation")
|
|
173
|
+
return report
|
gridforge/errors.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Domain exceptions raised by GridForge."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GridForgeError(Exception):
|
|
5
|
+
"""Base class for expected GridForge errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GridSpecError(GridForgeError, ValueError):
|
|
9
|
+
"""A canonical grid specification is invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DatasetError(GridForgeError, ValueError):
|
|
13
|
+
"""An input or output dataset violates its declared contract."""
|
gridforge/grid/build.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Build canonical cell geometries and stable per-cell identifiers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
from shapely.geometry import box
|
|
7
|
+
|
|
8
|
+
from gridforge.errors import DatasetError
|
|
9
|
+
from gridforge.grid.spec import GridSpec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_grid(spec: GridSpec) -> gpd.GeoDataFrame:
|
|
13
|
+
"""Create every canonical cell intersecting the requested bounds."""
|
|
14
|
+
row_start, row_end, col_start, col_end = spec.index_extent
|
|
15
|
+
rows: list[dict[str, object]] = []
|
|
16
|
+
for row in range(row_start, row_end + 1):
|
|
17
|
+
top_decimal = spec.origin.y - row * spec.cell_size
|
|
18
|
+
bottom_decimal = top_decimal - spec.cell_size
|
|
19
|
+
top = float(top_decimal)
|
|
20
|
+
bottom = float(bottom_decimal)
|
|
21
|
+
for column in range(col_start, col_end + 1):
|
|
22
|
+
left_decimal = spec.origin.x + column * spec.cell_size
|
|
23
|
+
right_decimal = left_decimal + spec.cell_size
|
|
24
|
+
left = float(left_decimal)
|
|
25
|
+
right = float(right_decimal)
|
|
26
|
+
rows.append(
|
|
27
|
+
{
|
|
28
|
+
"grid_id": f"{row}:{column}",
|
|
29
|
+
"grid_fingerprint": spec.fingerprint,
|
|
30
|
+
"row": row,
|
|
31
|
+
"column": column,
|
|
32
|
+
"left": left,
|
|
33
|
+
"bottom": bottom,
|
|
34
|
+
"right": right,
|
|
35
|
+
"top": top,
|
|
36
|
+
"geometry": box(left, bottom, right, top),
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
grid = gpd.GeoDataFrame(rows, geometry="geometry", crs=spec.crs)
|
|
40
|
+
grid.attrs["gridforge_spec"] = spec.to_dict()
|
|
41
|
+
return grid
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_grid_spec(grid: gpd.GeoDataFrame) -> GridSpec:
|
|
45
|
+
"""Recover and verify the GridForge spec attached to a canonical grid."""
|
|
46
|
+
value = grid.attrs.get("gridforge_spec")
|
|
47
|
+
if value is None:
|
|
48
|
+
raise DatasetError("grid is missing GridForge specification metadata")
|
|
49
|
+
try:
|
|
50
|
+
spec = GridSpec.from_mapping(value)
|
|
51
|
+
except ValueError as exc:
|
|
52
|
+
raise DatasetError(f"grid specification metadata is invalid: {exc}") from exc
|
|
53
|
+
if grid.crs is None or not spec.crs.equals(grid.crs):
|
|
54
|
+
raise DatasetError("grid CRS does not match GridForge specification")
|
|
55
|
+
if "grid_fingerprint" not in grid or not grid["grid_fingerprint"].eq(spec.fingerprint).all():
|
|
56
|
+
raise DatasetError("grid fingerprint does not match GridForge specification")
|
|
57
|
+
return spec
|